Skip to content

Mutation-check hardening + dependency lock (GAP-016)#66

Open
Bobcatsfan33 wants to merge 6 commits into
mainfrom
mutation-hardening
Open

Mutation-check hardening + dependency lock (GAP-016)#66
Bobcatsfan33 wants to merge 6 commits into
mainfrom
mutation-hardening

Conversation

@Bobcatsfan33

@Bobcatsfan33 Bobcatsfan33 commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Two commits: the four review notes, then the lock file.

The four notes

1. A claim of mine had no in-repo backing. I reported "#65 mutation-verified — kills exactly the 4 the review named." The CI mutation check that existed was #64's, and it mutates the SDK fail-closed default, not the exporter gate. My #65 run was local and one-time — a claim about a moment on my laptop, which is precisely what the house rule forbids. The reviewer was right that this is small and right that it matters.

Fixed by generalising the script (now scripts/mutation_check.sh, since it is no longer SDK-only) with selectable targets, and adding the exporter gate as the second — the highest-value mutation target in the repo, as suggested. It reintroduces the blanket enabled:false carve-out and fails the build if test_siem_write_path_gating stays green. CI runs backend in the backend job and sdk in the SDK job, because the dependencies differ.

2. Dirty-tree guard. The byte-copy restore fixed what the script puts back; this fixes what a local user can lose. My own footgun is the test case — the first version restored via git checkout -- and ate the uncommitted change it existed to protect. Guarded per-file rather than whole-tree: refusing to run because an unrelated doc is dirty just teaches people to skip the check. MUTATION_CHECK_ALLOW_DIRTY=1 overrides. Verified both branches.

3. Unknown stored exporter types no longer fall into the gated branch and advise PLATFORM_ENABLE_SIEM_EXTENDED — advice the flag can never honour. Unreachable from HTTP, but the branch order was the bug, not the reachability. Test added.

4. Deduped routing-cases.json, plus a test so the table cannot regrow duplicates. A table read by two languages should not make either wonder if it missed a distinction. (Node drops 42 → 41 tests accordingly.)

GAP-016 — the lock

backend/requirements.lock: universal, fully hashed (98 packages, 2127 hashes), generated by scripts/lock.sh. CI installs with pip install --require-hashes -r requirements.lock; so does a developer — the same file, the same bytes. That equality is the fix.

It pins fastapi==0.139.2 — the version CI was already resolving. So local now matches CI, rather than the reverse.

  • Universal on purpose. One lock serves every interpreter in requires-python (>=3.11) and both linux CI and macOS dev. A per-platform lock is only true where it was generated — it would have reproduced the original bug with extra steps.
  • uv stays dev-time only. The app deps install from the hashed lock via pip; uv only generates the lock and runs in the sync check. Note the honest scope (spelled out in GAPS.md): uv/pip/pip-audit are version- or not-pinned, and PEP 517 build isolation still fetches setuptools/wheel unpinned at image build — this pins the dependency tree, not literally every byte at build time.
  • Two failure modes, two mechanisms. Drift: a CI step recompiles from pyproject and diffs (a drifted lock is worse than none — it reports a resolution nobody asked for while looking authoritative). Staleness: pinning by hash means a CVE'd dependency stays pinned, so Dependabot covers pip, npm (frontend + Node SDK), gomod, and the Actions themselves — a compromised action runs with the workflow's credentials.
  • Supply chain, not dev hygiene. --require-hashes means a swapped upstream fails the build instead of executing in it. It's also what makes the existing CycloneDX SBOM and SLSA provenance describe something reproducible — an SBOM built from a floating resolution documents one machine's afternoon.

Test plan

  • backend pytest tests/743 passed on the dev venv
  • backend pytest tests/743 passed on a scratch venv built only from the lock with --require-hashes, exactly as CI installs
  • scripts/lock.sh is idempotent (regenerating produces an empty diff — the CI sync check will not flap)
  • both mutation targets verified on a clean tree; dirty-tree guard verified to refuse and preserve the edit
  • python SDK 44; node SDK 41; go build/vet/test -race ok; frontend build ok
  • dependabot.yml and ci.yml parse

Next: the GAP-001 mounts (aibom, siem) with full HTTP + tenant-isolation treatment — the ratchet's first live test, and the first time the de-aliased exemption lists shrink.

🤖 Generated with Claude Code


Update (post-review): the drift check diffed only requirements.lock while lock.sh regenerates both — so a stale committed runtime lock (the file the signed image installs) could pass green. Fixed: the sync step now runs git diff --exit-code -- backend/, which covers both locks and any future one. Verified locally that a runtime lock hand-edited to carry pytest is caught.

Four review notes, all small, all the kind this repo wins by not letting slide.

1. My report claimed "#65 mutation-verified — kills exactly the 4 the review
   named". Nothing in the repo backed that: the CI mutation check was #64's,
   and it mutates the SDK fail-closed default, not the exporter gate. The #65
   run was local and one-time — a claim about a moment on my laptop, which is
   exactly what the house rule forbids.

   Generalises the script (now scripts/mutation_check.sh, since it is no longer
   SDK-only) with selectable targets, and adds the exporter gate as the second:
   it reintroduces the blanket `enabled:false` carve-out and fails the build if
   test_siem_write_path_gating stays green. CI runs `backend` in the backend job
   and `sdk` in the SDK job, because the deps differ.

2. Adds a dirty-tree guard. The byte-copy restore fixed what the script puts
   back; this fixes what a local user can LOSE if it dies at the wrong moment.
   My own footgun — the first version restored via `git checkout --` and ate the
   uncommitted change it existed to protect — is the test case. Guarded
   per-file, because refusing to run over an unrelated dirty doc just teaches
   people to skip the check. MUTATION_CHECK_ALLOW_DIRTY=1 overrides.

3. _validate_exporter_tier_on_update sent unknown stored types down the gated
   branch, advising PLATFORM_ENABLE_SIEM_EXTENDED — which can never un-gate a
   type that does not exist. Unreachable from HTTP (ExporterType is a Literal),
   but the branch order was the bug, not the reachability. Unknown stored types
   are now judged on the payload's own merits, with a test.

4. Dedupes routing-cases.json ("explicit false overrides a dev environment" and
   "explicit false in a dev environment still fails closed" asserted the same
   env twice), plus a test so the table cannot regrow duplicates — a table read
   by two languages should not make either wonder if it missed a distinction.

Gates: backend 743 passed; python SDK 44; node SDK 41 (one duplicate case
retired); both mutation targets verified.
"Green locally" was not "green in CI", and it cost a real break to learn:
local resolved fastapi 0.136.1, CI resolved 0.139.2, and 0.139 had made
include_router lazy. Routing was identical — only introspection changed — so
the app was fine and the TESTS were wrong. CI was right and there was no local
way to discover it. Guardrail 4 ("full suite green before every commit") is
worth very little while the two suites run against different libraries.

backend/requirements.lock: universal, fully hashed (98 packages, 2127 hashes),
generated by scripts/lock.sh via `uv pip compile --universal --generate-hashes`.
CI installs with `pip install --require-hashes -r requirements.lock` and so does
a developer — the same file, the same bytes. That equality IS the fix.

Universal on purpose: one lock serves every interpreter in requires-python
(>=3.11) and both linux CI and macOS dev. A per-platform lock is only true where
it was generated, which would have reproduced the original bug with extra steps.
uv stays a dev-time tool; CI installs with plain pip, so nothing new is trusted
at build time to make trust work.

A lock has two failure modes, so there are two mechanisms:
- drift: a CI step recompiles from pyproject and diffs. A drifted lock is worse
  than none — it reports a resolution nobody asked for while looking
  authoritative.
- staleness: pinning by hash means a CVE'd dependency stays pinned. Dependabot
  covers pip, npm (frontend + Node SDK), gomod, and the Actions themselves — a
  compromised action runs with the workflow's credentials. pip-audit remains the
  backstop.

Framed as supply chain, not dev hygiene: --require-hashes means every artifact
must match a hash committed here, so a swapped upstream fails the build instead
of executing in it. It is also what makes the existing CycloneDX SBOM and SLSA
provenance describe something reproducible — an SBOM built from a floating
resolution documents one machine's afternoon.

Verified: the full backend suite passes against a scratch venv built only from
the lock (743 passed), and the lock pins fastapi==0.139.2 — the version CI was
already resolving, so local now matches CI rather than the reverse. lock.sh is
idempotent (regenerating produces an empty diff).
…, not the machine

CI's drift check caught a real defect on its first run — in the drift check's
own premise.

The diff was only in "# via" comments: anyio, starlette and pytest-asyncio
require typing-extensions on Python 3.12 but not on 3.14, and uv annotates
those comments using the markers of whichever interpreter runs the tool. So the
same command produced different bytes on macOS/3.14 than in CI/3.12. The
RESOLUTION was identical; the file was not — and a byte-diff cannot tell those
apart.

Which made the lock reproduce, in miniature, the exact bug it exists to fix:
output that depends on the machine that produced it.

--python-version is now pinned to requires-python's floor (3.11) in both
scripts/lock.sh and the CI sync check, so the output is a function of the FLAG.
Verified: two runs on 3.14 produce identical bytes; the 3.11-targeted lock
installs under 3.14 with --require-hashes; the full suite passes against it
(743).
…mmand

Second defect in the drift check's premise, same shape as the first.

uv embeds the VERBATIM command line in the generated file's header, including
the -o argument. The committed lock says "-o requirements.lock"; the check
compiled to "-o /tmp/requirements.check". So the two files differed on line 2
by construction, and would have every run forever — the check could never pass.

The artifact is a function of the exact invocation, so the only honest way to
verify it is to make the exact invocation. CI now runs scripts/lock.sh itself
and diffs against a saved copy. The check and the tool can no longer drift
apart, because there is only one command.

Verified by simulating the CI steps locally byte-for-byte: identical.
…GAP-016)

The image was the one thing GAP-016 didn't pin, and it is the one thing
security.yml Trivy-scans, SBOM-attests and cosign-signs. backend/Dockerfile
did `pip install .` — a floating resolution — so the pinning claim was loudest
about the artifact it did not cover. No number of signatures makes an SBOM
reproducible when the contents were decided by whatever PyPI served that
afternoon.

The Dockerfile now installs from a hashed lock with --require-hashes, then the
project with --no-deps.

A SECOND lock, not the existing one: the runtime stage does
`COPY --from=builder /install /usr/local`, so anything in the builder ships. The
all-extras lock would put pytest/ruff/mypy/bandit in production (100 pkgs vs
75), inflating what A-2 shrank and adding 25 packages of Trivy surface. So:

  requirements.lock          --all-extras  (100)  CI + dev
  requirements-runtime.lock  no extras     ( 75)  the image

The runtime lock is --constraint'ed to the full lock so the two agree VERSION
for version. Without that they drift: the first cut had CI testing
huggingface-hub 1.23.0 while the image shipped 1.24.0 (a dev extra held the
transitive back) — GAP-016 reintroduced one level down. My own guard test
caught it before it left the branch.

test_dependency_locks.py guards the properties: both locks hashed; runtime
carries no dev tooling; runtime is a strict subset of full at identical
versions; and the Dockerfile actually installs from the runtime lock (the lock
protects the image only if the image uses it).

Review follow-ups, same PR:
- lock.sh now ENFORCES its uv pin instead of naming it — uv's output format is
  version-dependent, so a newer uv gives a deterministic but baffling CI
  rejection. Verified it refuses a mismatched uv.
- re-added the dev+explicit-false routing case: the dedupe removed a pair that
  was behaviourally but not MECHANICALLY duplicate ("dev" vs "development"), so
  the dedupe guard would not have caught it and the shorthand lost coverage.
  The guard's docstring now states that honest scope.

GAPS.md rewritten to match reality: the four things the lock does NOT cover
(Dependabot files nothing for transitive CVEs — pip-audit is the sole signal;
build-backend deps unpinned under PEP 517 isolation; uv/pip/pip-audit not
hash-pinned; sdks/python CI unpinned = new GAP-017). The "nothing new is
trusted at build time" overstatement is gone.

Verified: full suite 758 passed on dev venv AND on a venv built only from the
full lock; the app builds and serves 82 paths on the RUNTIME-only lock with
zero dev tools installed; both locks idempotent. NOT verified locally: the
Docker build itself (no local Docker) — CI's build-scan-sign job is the check.
Same bug class as the one it guards, one layer up. lock.sh regenerates both
locks in the tree, but the sync step diffed only requirements.lock — and it
runs before pytest, so test_dependency_locks.py validated the FRESHLY
REGENERATED runtime lock, never the committed one. A committed
requirements-runtime.lock that was stale, hand-edited, or even carrying pytest
passed CI entirely green — and that committed file is exactly what the Docker
image installs and cosign signs. "The thing we test is not the thing we ship",
reintroduced inside the drift check itself.

Fixed with `git diff --exit-code -- backend/` after running lock.sh: it fails
if lock.sh changed anything at all, so it covers both locks and any future one
for free, without naming files. Verified locally that a runtime lock
hand-edited to carry pytest is caught (the previous per-file check let it pass).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant