From 7650223fb7e3596af51154bb8e69ac8901d07203 Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 15:47:22 -0700 Subject: [PATCH 1/7] Warm every failure-campaign lane before taking the leak baseline The first real HTTP round trip in a VM lazily starts services that then live for the rest of the VM: Plug.Cowboy attaches its :plug_cowboy telemetry handler on first use and the kernel starts inet_gethost_native (two processes and one port) on the first hostname lookup. The campaign only started the :ex_mcp application before snapshotting, so whenever it was the VM's first HTTP user those showed up as added_processes=2, added_ports=1, added_telemetry_handlers=1 and deterministic_complete went false. Whether that happened depended on which test in the file ran the campaign first, i.e. on the ExUnit seed (7 of 20 local runs failed). Run each deterministic lane once inside prepare_runtime, discard the results, take the baseline afterwards, and record the warmed lanes in the artifact's runtime_warmup. --- bench/imp/benchmark_truth/failure_campaign.ex | 59 +++++++++++-------- test/failure_campaign_test.exs | 3 + 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/bench/imp/benchmark_truth/failure_campaign.ex b/bench/imp/benchmark_truth/failure_campaign.ex index fe819dd6..6e72b2bc 100644 --- a/bench/imp/benchmark_truth/failure_campaign.ex +++ b/bench/imp/benchmark_truth/failure_campaign.ex @@ -42,19 +42,15 @@ defmodule Imp.BenchmarkTruth.FailureCampaign do validate_positive!(:iterations, iterations) validate_positive!(:max_concurrency, max_concurrency) validate_positive!(:iteration_timeout_ms, iteration_timeout_ms) - warmup = prepare_runtime(opts) + warmup = prepare_runtime(opts, max_concurrency, iteration_timeout_ms) baseline = runtime_snapshot() telemetry = start_telemetry_capture() cases = - Enum.map(@deterministic_lanes, fn - {id, :concurrency} -> - repeat(id, iterations, iteration_timeout_ms, fn -> - concurrency_iteration(max_concurrency) - end) - - {id, handler} -> - repeat(id, iterations, iteration_timeout_ms, fn -> run_lane(handler) end) + Enum.map(@deterministic_lanes, fn {id, handler} -> + repeat(id, iterations, iteration_timeout_ms, fn -> + lane_iteration(handler, max_concurrency) + end) end) live_cases = run_live_cases(opts) @@ -124,6 +120,9 @@ defmodule Imp.BenchmarkTruth.FailureCampaign do } end + defp lane_iteration(:concurrency, max_concurrency), do: concurrency_iteration(max_concurrency) + defp lane_iteration(handler, _max_concurrency), do: run_lane(handler) + defp run_lane(:cancellation), do: cancellation_iteration() defp run_lane(:timeout), do: timeout_iteration() defp run_lane(:partial_stream), do: partial_stream_iteration() @@ -827,32 +826,46 @@ defmodule Imp.BenchmarkTruth.FailureCampaign do end end - defp prepare_runtime(opts) do + defp prepare_runtime(opts, max_concurrency, iteration_timeout_ms) do # The MCP lane explicitly consumes the optional protocol application. Start # its shared supervisors before measuring per-operation resource leaks; # ordinary Imp startup intentionally does not start ExMCP. {:ok, _} = Application.ensure_all_started(:ex_mcp) + warmed_lanes = warm_deterministic_lanes(max_concurrency, iteration_timeout_ms) + + warmup = %{ + "performed" => true, + "warmed_lanes" => warmed_lanes, + "network_hosts" => [], + "external_network" => false, + "billable_generation" => false + } if Keyword.get(opts, :live, false) do - %{ - "performed" => true, + Map.merge(warmup, %{ "authority" => "local_injected_transport", - "network_hosts" => [], - "external_network" => false, - "billable_generation" => false, "dummy_canary_sha256" => sha256(@dummy_canary) - } + }) else - %{ - "performed" => true, - "authority" => "local_protocol_runtime", - "network_hosts" => [], - "external_network" => false, - "billable_generation" => false - } + Map.put(warmup, "authority", "local_protocol_runtime") end end + # Run every deterministic lane once before the leak baseline is taken. The + # first real HTTP round trip in a VM lazily starts services that then live for + # the rest of the VM: Plug.Cowboy attaches its :plug_cowboy telemetry handler + # on first use, and the kernel starts inet_gethost_native (two processes and + # one port) on the first hostname lookup. Neither is a per-iteration leak, but + # a cold baseline counted both as leaks whenever this campaign happened to be + # the VM's first HTTP user, which depended on the test seed. Warmup results + # are discarded; the measured iterations report any lane failure. + defp warm_deterministic_lanes(max_concurrency, iteration_timeout_ms) do + Enum.map(@deterministic_lanes, fn {id, handler} -> + _ = normalize(fn -> lane_iteration(handler, max_concurrency) end, 0, iteration_timeout_ms) + id + end) + end + defp repeat_live(id, iterations, timeout_ms, fun) do started_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() baseline = runtime_snapshot() diff --git a/test/failure_campaign_test.exs b/test/failure_campaign_test.exs index 67c168ab..7e2c164b 100644 --- a/test/failure_campaign_test.exs +++ b/test/failure_campaign_test.exs @@ -47,6 +47,9 @@ defmodule Imp.FailureCampaignTest do "remaining_live_lanes" => 2 } + assert get_in(artifact, ["configuration", "runtime_warmup", "warmed_lanes"]) == + artifact["scope"] + assert artifact["runtime"]["leak_free"] assert artifact["runtime"]["leaks"] == %{ From 345f94326d2062181ca87dee5712211bfe3af93f Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 15:47:22 -0700 Subject: [PATCH 2/7] Bump nltk to 3.10.3 in the ifbench parity requirements Closes the seventeen open dependabot alerts with a patched release (GHSA-8mgp-746c-j5xp has no patched release and stays open). --- benchmarks/requirements-ifbench-parity.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/requirements-ifbench-parity.txt b/benchmarks/requirements-ifbench-parity.txt index b8ce8d0e..aa76549d 100644 --- a/benchmarks/requirements-ifbench-parity.txt +++ b/benchmarks/requirements-ifbench-parity.txt @@ -1,7 +1,7 @@ emoji==2.15.0 immutabledict==4.3.1 langdetect==1.0.9 -nltk==3.10.0 +nltk==3.10.3 packaging==26.2 setuptools==83.0.0 syllapy==0.7.2 From a65edfe596b16b68d4500afb556c784e3d809bbc Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 15:47:23 -0700 Subject: [PATCH 3/7] Bump transformers to 5.10.1 in the TRL worker Relocked with uv lock --upgrade-package transformers; only transformers moved. Closes GHSA-xrqw-3rrv-vx5w for pyproject.toml and uv.lock. --- priv/trl_worker/pyproject.toml | 2 +- priv/trl_worker/uv.lock | 58 +++++++++++++++++----------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/priv/trl_worker/pyproject.toml b/priv/trl_worker/pyproject.toml index ff166776..01a81139 100644 --- a/priv/trl_worker/pyproject.toml +++ b/priv/trl_worker/pyproject.toml @@ -6,7 +6,7 @@ dependencies = [ "datasets==5.0.1", "peft==0.18.1", "torch==2.13.0", - "transformers==5.5.0", + "transformers==5.10.1", "trl==1.6.0", ] diff --git a/priv/trl_worker/uv.lock b/priv/trl_worker/uv.lock index 8241ad1f..a48f4c91 100644 --- a/priv/trl_worker/uv.lock +++ b/priv/trl_worker/uv.lock @@ -171,7 +171,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -196,43 +196,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -416,7 +416,7 @@ requires-dist = [ { name = "datasets", specifier = "==5.0.1" }, { name = "peft", specifier = "==0.18.1" }, { name = "torch", specifier = "==2.13.0" }, - { name = "transformers", specifier = "==5.5.0" }, + { name = "transformers", specifier = "==5.10.1" }, { name = "trl", specifier = "==1.6.0" }, ] @@ -556,7 +556,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -595,7 +595,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -607,7 +607,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -637,9 +637,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -651,7 +651,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1033,7 +1033,7 @@ wheels = [ [[package]] name = "transformers" -version = "5.5.0" +version = "5.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -1046,9 +1046,9 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/9d/fb46e729b461985f41a5740167688b924a4019141e5c164bea77548d3d9e/transformers-5.5.0.tar.gz", hash = "sha256:c8db656cf51c600cd8c75f06b20ef85c72e8b8ff9abc880c5d3e8bc70e0ddcbd", size = 8237745, upload-time = "2026-04-02T16:13:08.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/d3/5b7c2f1a52ff0e57355efdc21554aab7e4602f6592ab4582a34c988ab956/transformers-5.10.1.tar.gz", hash = "sha256:31112d1dcdfcf9934242acbba891f44e2279ff74b9b8ba4595640e0e04195a3a", size = 8798372, upload-time = "2026-06-03T15:37:03.289Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl", hash = "sha256:821a9ff0961abbb29eb1eb686d78df1c85929fdf213a3fe49dc6bd94f9efa944", size = 10245591, upload-time = "2026-04-02T16:13:03.462Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8c/3119596c7fcd9b8b8d924d5b5ba1bfdfafd6d8738bdd81ca09c60b4a38b3/transformers-5.10.1-py3-none-any.whl", hash = "sha256:ccb919ea1b77338b44d0d45d23f7472081906b1bb6ed8e5f5cf4d692d1da03d4", size = 11003770, upload-time = "2026-06-03T15:37:00.433Z" }, ] [[package]] From 6059b8d309e9ed86d31d7ea375056b2eaea07d54 Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 16:04:32 -0700 Subject: [PATCH 4/7] Audit dependencies with mix_audit and record the three cowlib ignores mix hex.audit serves the ERLEF feed, where the three open cowlib records have no fixed version (introduced 2.9.0, open-ended), so it flagged every cowlib release that exists and quality.check could not go green by any dependency change. The advisory step is now mix deps.audit, whose database records a fixed range per advisory; hex.audit stays for retired packages, which mix_audit does not check. .audit_ignore holds the three ids with what was verified for each and the condition that retires it. Both steps read that one file. Two of the three are not false positives: cowlib 2.20.0 still ships the unfixed cow_http_struct_hd:escape_string/2 and cow_cookie:cookie/1, and no fixed release exists. They are ignored because the mitigation is elsewhere, and test/dependency_advisory_mitigation_test.exs locks that: cowboy is at or above 2.16.0 and refuses CR/LF in response headers by default, Plug raises on the same bytes, and no module in the imp application calls the cowlib functions under advisory. --- .audit_ignore | 72 ++++++++++++++++++++ mix.exs | 32 ++++++++- mix.lock | 3 + test/dependency_advisory_mitigation_test.exs | 70 +++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 .audit_ignore create mode 100644 test/dependency_advisory_mitigation_test.exs diff --git a/.audit_ignore b/.audit_ignore new file mode 100644 index 00000000..8fea9587 --- /dev/null +++ b/.audit_ignore @@ -0,0 +1,72 @@ +# Dependency-advisory ignores for `mix quality.check`. +# +# Format: one advisory id per line; `#` lines are comments. The gate reads this +# file twice: `mix deps.audit --ignore-file .audit_ignore` (mix_audit) reads it +# directly, and `mix hex.audit` reads it through audit_ignored_advisory_ids/0 in +# mix.exs. hex.audit stays in the gate because mix_audit does not check for +# retired packages; with these ids ignored it still fails on a retirement or on +# any advisory not listed here. +# +# Every entry records what was verified, and the condition that retires it. +# Verified 2026-09-15 against the ERLEF CNA records that hex.audit serves +# (https://api.osv.dev/v1/vulns/), the GitHub Advisory Database +# (`gh api /advisories?cve_id=`), and the cowlib and cowboy sources at the +# versions mix.lock pins. +# +# Shared facts. mix.lock pins cowlib 2.20.0, the newest release on Hex, and +# cowboy 2.19.0. Both arrive only through ex_mcp, which imp declares +# `runtime: false`; imp's own Plug.Cowboy call sites are tests, bench and the +# source-checkout-only demo task, none of which ship in the Hex package. All +# three ERLEF records carry an open-ended Hex range (introduced 2.9.0, no fixed +# version), so hex.audit flags every cowlib release that exists, including the +# ones that carry the fix. That is why the advisory gate moved to mix_audit. + +# EEF-CVE-2026-43971 / CVE-2026-43971 (MEDIUM) - Link header directive smuggling +# via unescaped target/rel/attribute keys in cow_link:link/1. +# VERIFIED FIXED in the version we ship. The ERLEF record names fix commit +# 89da27ee4c241f5d649ba7d9b7f2188918af6cea ("Link: escape and validate values +# when building", 2026-08-18); in ninenines/cowlib, +# `git merge-base --is-ancestor 89da27ee 2.20.0` returns true, and the record's +# own GIT range ends at that commit and lists every affected version up to +# 2.19.0 but not 2.20.0. Only the open-ended Hex range still matches. +# GHSA-gg23-fwhr-prjh carries no affected package ranges at all. +# RETIRE when the ERLEF Hex range gains `fixed: 2.20.0`. +EEF-CVE-2026-43971 + +# EEF-CVE-2026-43966 / CVE-2026-43966 / GHSA-w4f7-4cxr-rv3c (MEDIUM) - HTTP +# response splitting via non-VCHAR bytes in cow_http_struct_hd:escape_string/2. +# NOT FIXED IN COWLIB: at tag 2.20.0 escape_string/2 still copies every other +# byte through verbatim (`escape_string(<>, Acc) -> escape_string(R, +# <>)`), escaping only \ and ", and the ERLEF GIT range has no +# fixed event. The fix shipped one layer up, in cowboy: GitHub records cowboy +# < 2.16.0 vulnerable, first patched 2.16.0, from commit f77cb9b5 "Add +# invalid_response_headers HTTP/1 option". We pin cowboy 2.19.0, and +# deps/cowboy/src/cowboy_http.erl defaults invalid_response_headers to +# `error_terminate`, which refuses any response header value containing CR or +# LF before it reaches the socket. Plug raises Plug.Conn.InvalidHeaderError on +# the same bytes a layer above that. test/dependency_advisory_mitigation_test.exs +# locks the cowboy floor, the Plug rejection, and that no :imp module calls +# cow_http_struct_hd. +# RETIRE when the ERLEF cowlib record gains a fixed range. If the lock ever +# drops below cowboy 2.16.0 the mitigation test fails first. +EEF-CVE-2026-43966 + +# EEF-CVE-2026-43969 / CVE-2026-43969 / GHSA-g2wm-735q-3f56 (LOW) - Cookie +# request-header injection via an unvalidated encoder in cow_cookie:cookie/1. +# NOT FIXED IN COWLIB AND NO FIXED RELEASE EXISTS. cookie/1 at tag 2.20.0 is +# still the unvalidated five-clause function, the ERLEF GIT range has no fixed +# event, and the only patch that exists anywhere is erlef/cowlib commit +# 177953dd "Preliminary patch for CVE-2026-43969" on a fork that upstream has +# not merged. GitHub's record disagrees with the source: it bounds the +# vulnerable range at cowlib <= 2.16.1 with no patched version, which is why +# mix_audit does not flag our 2.20.0. +# Reachability: cookie/1 builds an outgoing Cookie *request* header, so its +# callers are HTTP clients such as gun, which is not in this dependency tree. +# Nothing in deps/ outside cowlib itself calls it; imp speaks HTTP through +# Req/Finch/Mint; test/dependency_advisory_mitigation_test.exs asserts no :imp +# module imports cow_cookie:cookie/1. +# This one is ignored because no upgrade can remove it, not because it is a +# false positive. It is reported in PR #107 rather than buried here. +# RETIRE the moment cowlib publishes a release that validates cookie/1, and +# move the lock to that release. +EEF-CVE-2026-43969 diff --git a/mix.exs b/mix.exs index a671d571..11c96f76 100644 --- a/mix.exs +++ b/mix.exs @@ -23,6 +23,7 @@ defmodule Imp.MixProject do skip_code_autolink_to: &skip_filtered_doc_reference?/1 ], start_permanent: Mix.env() == :prod, + hex: [ignore_advisories: audit_ignored_advisory_ids()], elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), aliases: aliases(), @@ -120,7 +121,8 @@ defmodule Imp.MixProject do {:stream_data, "~> 1.1", only: :test}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:ex_doc, "~> 0.35", only: [:dev, :test], runtime: false} + {:ex_doc, "~> 0.35", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false} ] end @@ -404,8 +406,18 @@ defmodule Imp.MixProject do "dialyzer.check": [ "dialyzer" ], + # Dependency advisories run through mix_audit, whose database records a + # fixed range per advisory. mix hex.audit serves the ERLEF feed, where the + # three open cowlib records are open-ended (introduced 2.9.0, no fixed + # version), so it flags every cowlib release that exists including the one + # carrying the fix. hex.audit stays in the gate for retired packages, + # which mix_audit does not check, with those ids ignored from + # .audit_ignore -- one file holding each id next to what was verified and + # what retires it. Both run as child invocations: mix deps.audit stops the + # VM when it finds something. "quality.check": [ "credo --only warning", + "cmd mix deps.audit --ignore-file .audit_ignore", "cmd mix hex.audit" ] ] @@ -509,6 +521,24 @@ defmodule Imp.MixProject do ] end + # Advisory ids the quality gate accepts, read from .audit_ignore so the ids, + # the reason each was verified to be safe to ignore, and the condition that + # retires it live in one file that both audit steps read. Absent in a Hex + # package checkout, where the gates do not run. + defp audit_ignored_advisory_ids do + path = Path.expand(".audit_ignore", __DIR__) + + if File.regular?(path) do + path + |> File.read!() + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#"))) + else + [] + end + end + defp benchmark_tasks_available? do File.exists?("lib/mix/tasks/imp.benchmark.run.ex") end diff --git a/mix.lock b/mix.lock index 19e13be9..daf4bfa1 100644 --- a/mix.lock +++ b/mix.lock @@ -34,6 +34,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, @@ -55,5 +56,7 @@ "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "zoi": {:hex, :zoi, "0.18.5", "fc23f53d5ba6c8639fb30d9a70a80ae8fed774c180c2544bfd16f53bedf95dff", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "d69c2e6468752367941d0264e21cf229c1ba8757b2973eea17c12b3cb7d2d4c4"}, } diff --git a/test/dependency_advisory_mitigation_test.exs b/test/dependency_advisory_mitigation_test.exs new file mode 100644 index 00000000..c33cab73 --- /dev/null +++ b/test/dependency_advisory_mitigation_test.exs @@ -0,0 +1,70 @@ +defmodule DependencyAdvisoryMitigationTest do + @moduledoc """ + Locks the claims that `.audit_ignore` makes about the three open cowlib + advisories. Each ignore entry there says why the advisory cannot reach imp; + these tests fail if that stops being true, so the reasons cannot rot into + prose. + """ + + use ExUnit.Case, async: true + + @vulnerable_functions [ + # EEF-CVE-2026-43969: unvalidated encoder, still unfixed in cowlib 2.20.0. + {:cow_cookie, :cookie, 1}, + # EEF-CVE-2026-43971: fixed in cowlib 2.20.0, kept here as a floor. + {:cow_link, :link, 1} + ] + + test "no module in the imp application calls a cowlib function under advisory" do + imports = application_imports(:imp) + + for mfa <- @vulnerable_functions do + refute mfa in imports + end + + refute Enum.any?(imports, fn {module, _function, _arity} -> + module == :cow_http_struct_hd + end) + end + + test "cowboy carries the EEF-CVE-2026-43966 response-header mitigation" do + _ = Application.load(:cowboy) + version = :cowboy |> Application.spec(:vsn) |> List.to_string() + + assert Version.compare(version, "2.16.0") in [:eq, :gt], + "cowboy #{version} predates the invalid_response_headers option" + + # Default options: cowboy must refuse a response header carrying CR or LF + # rather than serialising cowlib's unescaped bytes onto the socket. + assert :error_terminate = + :cowboy_http.validate_response_headers( + %{<<"x-test">> => <<"safe\r\nx-injected: true">>}, + %{} + ) + + assert :ok = :cowboy_http.validate_response_headers(%{<<"x-test">> => <<"safe">>}, %{}) + end + + test "Plug refuses the same bytes one layer above cowboy" do + conn = Plug.Test.conn(:get, "/") + + for invalid <- ["safe\r\nx-injected: true", "safe\n", <<"safe", 0>>] do + assert_raise Plug.Conn.InvalidHeaderError, fn -> + Plug.Conn.put_resp_header(conn, "x-test", invalid) + end + end + end + + defp application_imports(application) do + {:ok, modules} = :application.get_key(application, :modules) + + Enum.flat_map(modules, fn module -> + {:module, ^module} = Code.ensure_loaded(module) + + case :beam_lib.chunks(:code.which(module), [:imports]) do + {:ok, {_module, [imports: imports]}} -> imports + _unavailable -> [] + end + end) + end +end From 78dd8d2b022b6ad40f7e6035d8c980bfa393b9c5 Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 16:34:42 -0700 Subject: [PATCH 5/7] Drop the dialyzer ignore that the failure-campaign fix made unnecessary --- .dialyzer_ignore.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 362baacc..668badc2 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -15,7 +15,6 @@ # exsss export shape; the construction is intentional and correct. [ # defensive clause for non-covered result shapes - {"bench/imp/benchmark_truth/failure_campaign.ex", :pattern_match_cov, {169, 13}}, # MapSet opacity on the stopword set (this one renders only under # --format raw; the default formatter drops it, the count still sees it) {"bench/imp/benchmark_truth/hover_bm25.ex", :call_without_opaque, {191, 36}}, From e10770a3665b9411c58a23472ecb1c0381390845 Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 16:49:50 -0700 Subject: [PATCH 6/7] Move the failure-campaign dialyzer ignore to the line the fix shifted it to --- .dialyzer_ignore.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 668badc2..1492c974 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -232,4 +232,5 @@ {"lib/imp/optimizer/playbook.ex", :pattern_match_cov, {1014, 8}}, {"lib/imp/optimizer/report.ex", :call_without_opaque, {755, 55}}, {"lib/imp/schema.ex", :pattern_match_cov, {459, 8}} + {"bench/imp/benchmark_truth/failure_campaign.ex", :pattern_match_cov, {168, 13}}, ] From d8af740165620a4cf8828d92e688d0db9f1bae02 Mon Sep 17 00:00:00 2001 From: deepfates Date: Tue, 15 Sep 2026 17:04:50 -0700 Subject: [PATCH 7/7] Fix the list syntax in the dialyzer ignore file --- .dialyzer_ignore.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 1492c974..ee929149 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -231,6 +231,6 @@ {"lib/imp/optimizer/artifact.ex", :call_without_opaque, {909, 53}}, {"lib/imp/optimizer/playbook.ex", :pattern_match_cov, {1014, 8}}, {"lib/imp/optimizer/report.ex", :call_without_opaque, {755, 55}}, - {"lib/imp/schema.ex", :pattern_match_cov, {459, 8}} - {"bench/imp/benchmark_truth/failure_campaign.ex", :pattern_match_cov, {168, 13}}, + {"lib/imp/schema.ex", :pattern_match_cov, {459, 8}}, + {"bench/imp/benchmark_truth/failure_campaign.ex", :pattern_match_cov, {168, 13}} ]