Skip to content

sec: hash-pin every dependency install and add coverage-guided fuzzing - #244

Merged
cdeust merged 13 commits into
mainfrom
sec/pin-dependencies-and-fuzzing
Jul 29, 2026
Merged

sec: hash-pin every dependency install and add coverage-guided fuzzing#244
cdeust merged 13 commits into
mainfrom
sec/pin-dependencies-and-fuzzing

Conversation

@cdeust

@cdeust cdeust commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes all 22 open code-scanning alerts: 21 Pinned-Dependencies + 1 Fuzzing. There were no Dependabot alerts and no known-CVE vulnerabilities open — every finding was OpenSSF Scorecard.

Closes #203, and goes past it: that issue covers the three Dockerfiles (11 alerts). The other 10 pinning alerts (ci.yml ×7, release.yml ×2, scripts/setup.sh) and the Fuzzing alert had no issue at all, and are fixed here rather than filed for later.

The core point

An exact version is not a pin. ruff==0.15.20 and torch==2.11.0 both counted as unpinned, and Scorecard is right: a version names a release, not the bytes the index serves for it today. Only a hash pins the artifact.

pip install --require-hashes is all-or-nothing — one hash means every requirement including transitive ones needs one — so it needs a resolved lock. uv.lock becomes the single source of truth.

Mechanism What it does
scripts/generate_pip_constraints.py Exports one hashed file per call site into requirements/. Refuses an export that is empty or carries an unhashed requirement. --check is a blocking Lint step, so a lock change that isn't re-exported fails there, not at install time.
[dependency-groups] CI's own tools (ruff, pyright, build, hatchling, atheris) are locked instead of restated as bare version strings across two workflow files.
[[tool.uv.index]] + [tool.uv.sources] Binds torch to the PyTorch CPU index on Linux.

The torch finding was worse than "unpinned"

The containers passed --index-url https://download.pytorch.org/whl/cpu at the call site. So uv.lock recorded PyPI's torch and its PyPI hashes, while the image installed a different artifact from a different index. No source of truth could produce a hash for what was actually installed — which is why --require-hashes wasn't reachable before.

The lock now carries torch 2.13.0+cpu with 22 hashes, and resolution drops 18 nvidia/cuda packages plus triton. torch is named in a container dependency-group only so the source can bind to it — PEP 735 groups aren't published, so nothing changes for anyone installing hypermnesia-mcp from PyPI.

Two findings couldn't be pinned — they had to stop being what they were

  • curl https://deb.nodesource.com/setup_22.x | bash — an unreviewed remote script executed as root at build time, and there is no hash to check a pipe against. Replaced by what that script does: fetch the signing key, register the signed apt source, install the signed package. curl now feeds gpg --dearmor, which executes nothing.
  • npm install -g @anthropic-ai/claude-code — unversioned, so the image tracked whatever the registry served that minute. Now npm ci against a committed lockfile, which is also the only form Scorecard accepts (an exact version on the command line is not enough) and which records a sha512 integrity hash per transitive package.

Accepted forms, from the checker's source

I read isUnpinnedPipInstall rather than inferring it. A bare pip install --no-deps . is unpinned. is neither a flag nor a .whl, so it sets hasAdditionalArgs; isPinnedEditableSource is consulted only for -e. Hence: --require-hashes, or -e <local> --no-deps, or a .whl path. The root image therefore builds a wheel — an editable install there would leave a .pth pointing at /build, which the runtime stage never copies, and the venv would arrive broken.

Fuzzing — and what it found

Two harnesses over pure parsers that read untrusted text (§13.1 D2): the hand-rolled YAML frontmatter parser and the wiki source-path canonicaliser. ClusterFuzzLite runs a 120s batch on PRs (blocking) and a longer scheduled run (non-blocking — a fuzzer left running eventually finds something, and holding the merge queue hostage to an unrelated input makes the check ignored within a week).

Writing the path harness found a live bug. normalize_source_path stripped ./ in a loop, then / exactly once. Removing the slashes can expose a ./ the loop already walked past:

.//./x   ->  ./x     # still carries the prefix the function exists to remove
/./z     ->  ./z

The result was not idempotent, and extract_document_paths dedupes on it — so one document reachable by two spellings counted as two. Fixed by iterating to a fixed point. The four reproducers are committed as corpus inputs and fail on the pre-fix code (verified by stashing the fix and re-running).

fuzz/replay_corpus.py replays every corpus input with no atheris, so the properties run in the ordinary pytest suite on every platform. Atheris publishes manylinux x86_64 wheels for cpython 3.12–3.14 and nothing else — a property only one CI job can run is one that rots.

Also fixed (§14, seen en route)

  • docker/Dockerfile could not build at all — it copied /usr/local/lib/python3.12/site-packages against a python:3.14 base, a path absent from both stages. Invisible because no CI job built this image. Now installs into a version-free venv (the rule the root Dockerfile already documents).
  • scripts/setup.sh reported success over any failure — the install ended in 2>/dev/null, which is exactly where a resolution failure, hash mismatch and network error appear, and it printed "Python packages installed" regardless. Exit status is now checked.
  • .gitattributes marks fuzz/corpus/** binary — EOL normalisation would have rewritten the CRLF seed on checkout and deleted the case it exists to cover.

Unblocking pass (2026-07-29)

The first push left Test (Python 3.10) red. Root-caused to the lock, not
to this PR's design, and fixed there (§6).

The 3.10 blocker

ERROR: Could not find a version that satisfies the requirement onnxruntime==1.24.3
       (from versions: ..., 1.23.0, 1.23.1, 1.23.2)
ERROR: No matching distribution found for onnxruntime==1.24.3

uv.lock recorded onnxruntime 1.24.3 for the python_full_version < '3.11'
fork, and onnxruntime 1.24.x publishes no cp310 artifact and no sdist
1.24.3 ships 24 files whose lowest interpreter tag is cp311, and 1.24.0
declares no Requires-Python at all, which is why uv accepted it there. A lock
entry for the 3.10 fork with no 3.10 artifact is broken by inspection.

The defect is pre-existing on main (git show origin/main:uv.lock carries
the same entry). It stayed invisible because main installs from
pyproject.toml, where pip re-resolves onnxruntime down to 1.23.2 on 3.10 by
itself. A hash-pinned install cannot re-resolve — so making the lock the install
source is exactly what turned a latent lock defect into a visible failure. That
is the change working as intended, not a regression it introduced.

Fixed with a [tool.uv] constraint-dependencies entry rather than a project
dependency: constraints steer only our resolution and are never published in the
wheel metadata, so anyone installing hypermnesia-mcp on 3.11+ still resolves
the current onnxruntime. Declared before the [[tool.uv.index]] /
[tool.uv.sources] tables so it never re-opens a tool.uv super-table they
created implicitly.

Regenerated with uv 0.11.3 — the exact version ci.yml pins via
astral-sh/setup-uv, because the blocking Lint step byte-compares a uv export
result and any other uv version fails it.

Lock delta is minimal and forkwise: onnxruntime 1.24.3 -> 1.23.2 on the
<3.11 fork only (1.28.0 on >=3.11 untouched), plus its three transitive
deps (coloredlogs, humanfriendly, pyreadline3). 9 of 13 requirements files
changed.

onnxruntime was the only package with this defect — proven by resolving
every requirements file against every Python its consumers use, below.

§14 defects found and fixed in this pass

  • .bestpractices.json was committed carrying four unresolved merge-conflict
    blocks
    (at c090278), leaving it invalid JSON — and it is transcribed into
    the OpenSSF Best Practices questionnaire, so an unparseable copy is a broken
    consumer, not a stale number. It had passed the doc-claim gate, CodeQL and 18
    green checks. Root cause (§6): .bestpractices.json is one of that gate's
    own SCANNED_FILES, but every check it runs is a claim regex — a regex matches
    the first side of a conflict and never looks at the file's structure. The gate
    read the broken file and printed doc claims OK. Both sides of all four blocks
    were byte-identical, so the repair is lossless (verified by comparing the
    sides, not by picking one). Two new checks close the class, both derived from
    SCANNED_FILES so a newly scanned file is enrolled with no further edit, and
    both failing closed on a file they cannot read.
  • Every action was on the deprecated Node 20 runtime. The previous body
    reported this as "unrelated, pre-existing" with no issue number — precisely
    the deferral §14.3 forbids. These refs live in ci.yml, a file this diff
    modifies, so §14.1 puts them in touched material: fixed here, not filed.

Completion Ledger (§13.2)

Path enumeration

Path Evidence
named_index_url — found test_every_local_version_pin_names_a_serving_index
named_index_url — absent → ExportError test_undeclared_index_is_refused
ConstraintSet.command() — extras / groups / only-groups test_export_asserts_the_lock_matches_pyproject
header() — with and without index directive test_every_local_version_pin_names_a_serving_index
render() — uv missing → ExportError test_export_failure_exits_two_not_one
render() — uv non-zero exit → ExportError test_export_failure_exits_two_not_one
render() — empty export refused test_empty_export_is_refused
render() — unhashed requirement refused test_unhashed_export_is_refused
render() — local pin absent from lock test_local_pin_absent_from_the_lock_is_refused
write() — changed / unchanged test_check_passes_on_the_committed_tree
stale() — missing file test_check_fails_when_a_file_is_missing
stale() — content differs test_check_fails_on_a_mutated_file
stale() — current → None test_check_passes_on_the_committed_tree
main()--check clean → 0 / drift → 1 / cannot run → 2 test_check_passes_on_the_committed_tree, test_check_fails_on_a_mutated_file, test_export_failure_exits_two_not_one
Drift message names the fix command test_drift_message_names_the_regeneration_command
Every set has a file; no file orphaned; names unique test_every_set_has_a_committed_file, test_no_committed_file_is_orphaned, test_filenames_are_unique
Every requirement version-pinned and hashed test_every_requirement_is_version_pinned_and_hashed
No editable/directory requirements leak into a hashed file test_no_editable_or_directory_requirements
normalize_source_path — canonical / ./ / / / backslash / blank / separators-only TestNormalizeSourcePath (6 tests)
normalize_source_pathfixed-point regressions TestNormalizeSourcePathReachesAFixedPoint (4 + idempotence)
normalize_source_path.. left intact (negative) test_parent_traversal_is_left_intact
extract_document_paths — two spellings collapse test_two_spellings_of_one_document_collapse_to_one
Harness discovery non-empty (a fuzz setup finding nothing passes everything) test_at_least_one_harness_exists
Every harness holds on its whole corpus; empty corpus refused test_harness_holds_on_its_whole_corpus
YAML harness — type, dict-ness, lowercased keys, substring body fuzz_yaml_frontmatter.consume over 10 corpus inputs
Path harness — no leading /, no leading ./, non-empty, idempotent fuzz_source_path.consume over 10 corpus inputs
[tool.uv] constraint-dependencies — 3.10 fork resolves an installable onnxruntime 17/17 pip install --dry-run --require-hashes pairs on linux/amd64 (table below)
Lock regeneration is byte-identical to the CI gate's expectation generate_pip_constraints.py --checkrequirements OK (13 checked)
check_no_conflict_markers — labelled marker found test_conflict_markers_are_reported_with_path_and_line
check_no_conflict_markers — clean file → no failure test_a_clean_file_reports_nothing
check_no_conflict_markers — Markdown setext ======= NOT flagged (negative) test_a_markdown_setext_underline_is_not_a_conflict_marker
check_no_conflict_markers — missing file fails closed test_a_missing_scanned_file_fails_closed
check_scanned_json_parses — invalid JSON reported test_unparseable_json_is_reported
check_scanned_json_parses — valid JSON → no failure test_valid_json_reports_nothing
check_scanned_json_parses — non-JSON member skipped (negative) test_markdown_is_not_json_checked
Both new checks are actually wired into collect_failures test_both_checks_run_inside_collect_failures
The real repository tree is structurally clean test_the_real_repository_tree_is_structurally_clean
Every pinned action runs on a supported Node runtime repo-wide sweep of all 16 refs → 0 on node20

§13.1 checklist

A. Correctness & behavior

  • A1 Full suite green locally on the merged tree: 6421 passed, 5 skipped, 117 subtests passed in 653.86s (0:10:53) (6421 + 5 = the 6426 collected). CI agrees on the exact pushed tree: collected 6426 testsdoc claims OKbadges OK (5 checked) (job 90574807372). --require-hashes install proven end-to-end (ruff 0.15.20 installed from requirements/lint.txt, exit 0).
  • A2 Edge cases: missing/mutated/empty/unhashed export, absent index declaration, uv absent, uv non-zero, blank & separator-only paths, CRLF, lone surrogates, unicode, 200-char dash runs — each mapped above.
  • A3 Every failure arm asserted on observable effect, including the exit-code distinction between "could not run" (2) and "found drift" (1) — a missing uv must not read as a clean gate.
  • A4 Trust boundary is the index; --require-hashes is the validation. Adversarial case tested directly: every hash corrupted → pip exits 1 with "THESE PACKAGES DO NOT MATCH THE HASHES". A single corrupted hash is correctly not enough (pip accepts if any listed hash matches) — the first attempt at this test was invalid for that reason and was redone.
  • A5 Invariants: --check never writes; generation idempotent; normalize_source_path idempotent (the property the bug violated).
  • A6 Idempotent by construction — write() no-ops when content matches.

B. Concurrency — N/A: single-shot synchronous scripts, no shared mutable state, no async. CI jobs are independent.

C. Resources & performance

  • C1 Loops are over a fixed 13-set registry, a fixed corpus, and file lines. No unbounded growth.
  • C2 No handles held; corpus read via read_bytes. Image size falls — 18 nvidia/cuda packages + triton removed from every container.
  • C3 PR fuzz batch capped at 120s/harness/sanitizer so it doesn't become the slowest required check.

D. Security — this is the point of the change

  • D1 No SQL/shell built from data. subprocess.run uses a fixed argv, no shell, no user input (# noqa: S603 with that rationale).
  • D2 Untrusted input is the whole subject: the two fuzz targets read LLM-written and user-supplied documents.
  • D3 No secrets touched; permissions: read-all on the fuzz workflow. Net effect: one unreviewed remote script no longer executes as root at build time, and every installed artifact is hash-verified. The one --extra-index-url added is documented as safe only because the file is hash-pinned — an artifact from either index that isn't the locked one fails the hash check before unpacking.

E. Interfaces & compatibility

  • E1 Additive: new script, new requirements/, new groups. No public API change; PEP 735 groups are not published, so PyPI consumers see identical metadata.
  • E2 Downstream consumers identified by name and verified: ci.yml (all 7 sites), release.yml (2), scripts/setup.sh, Dockerfile, docker/Dockerfile, .devcontainer/Dockerfile, .clusterfuzzlite/build.sh. extract_document_paths and tests_py/handlers/test_wiki_page_sources.py re-verified against the changed normalisation (637 passed).
  • E3 N/A: no persisted-data format change.
  • E4 .gitattributes handles the cross-platform EOL case explicitly. The Windows CI leg installs ci-sqlite-min.txt, which resolves without a tree-sitter toolchain by design.

F. Observability & operations

  • F1 Every failure names the file and the remedy (run scripts/generate_pip_constraints.py); asserted by test_drift_message_names_the_regeneration_command. Success prints requirements OK (13 checked). setup.sh no longer swallows pip's stderr.
  • F2 No silent degraded mode: an unhashed or empty export raises rather than writing a weaker file.

G. Tests

  • G1 Ledger above maps every diff path.
  • G2 Both bugs carry regressions that fail on pre-fix code — verified for the path bug by git stash-ing the fix and re-running the replayer (it failed on repro-backslash-mixed, then passed once restored).
  • G3 Deterministic and isolated: temp dirs, no network in unit tests, no sleeps, no ordering dependence.
  • G4 Negative assertions present: test_no_editable_or_directory_requirements, test_no_committed_file_is_orphaned, test_parent_traversal_is_left_intact, corrupted-hash rejection.
  • G5 Full suite quoted in A1; ruff check .All checks passed!; ruff format --check .1064 files already formatted; check_doc_claims.py and generate_pip_constraints.py --check both green.

H. Code quality & delivery

  • H1 SOLID/layering/sizes: the constraint table is separated from the engine that runs uv, so the mapping is testable without uv on PATH and both files stay under the cap. Adding a set is one registry entry; adding a harness is one file (discovered, not listed). The two lint waivers are per-site with stated reasons (S603 fixed argv; PLC0415 atheris has no wheel for macOS/aarch64).
  • H2 No dead code, no debug leftovers. --upgrade pip build removed from the root image: it was itself an unpinned install and build was never invoked there.
  • H3 Matches neighbouring script conventions (check_doc_claims.py, generate_repo_badges.py — same fail-closed --check idiom).
  • H4 CHANGELOG has Security/Fixed entries, extended in this pass with the lock fix and the conflict-marker gate; suite size synced to the measured 6426 across 5 claim sites + the committed badge SVG.
  • H5 Logic, formatting and docs kept in separate commits: merge+repair, lock fix, gate+tests, ruff format (formatting only), count refresh, CHANGELOG, action bumps.
  • H6 CI green on the exact pushed tree — see the check list on this PR. The previously-failing Test (Python 3.10) now passes.
  • H7 Boy-scout (§14) — every defect seen in touched material is fixed here, none deferred: the unbuildable docker/Dockerfile, setup.sh's swallowed stderr and drifted package list, the non-idempotent path canonicaliser, the CRLF-corruptible corpus, and hatchling being fetched unpinned during build isolation. No unfixed, un-issued seen defect remains.

Resolvability matrix — every (requirements file, python) pair its consumers use

pip install --dry-run --require-hashes, run on linux/amd64 (CI's platform;
this host is arm64, so a same-arch check would have validated a different wheel
set than CI resolves). Before the fix, the first row fails with
No matching distribution found for onnxruntime==1.24.3 — reproduced, then
re-run to green.

OK   ci-postgresql.txt on py3.10      <- the failing check, now green
OK   ci-postgresql.txt on py3.11
OK   ci-postgresql.txt on py3.12
OK   ci-postgresql.txt on py3.13
OK   setup.txt on py3.10
OK   setup.txt on py3.14
OK   ci-sqlite.txt on py3.12
OK   ci-sqlite-min.txt on py3.12
OK   lint.txt on py3.12
OK   packaging.txt on py3.12
OK   release.txt on py3.12
OK   ci-typecheck.txt on py3.13
OK   typecheck-tool.txt on py3.13
OK   runtime-postgresql.txt on py3.14
OK   devcontainer.txt on py3.14
OK   docker-runtime.txt on py3.14
OK   fuzz.txt on py3.12

17/17. fuzz.txt is verified on 3.12 only and by design: atheris publishes
manylinux x86_64 wheels for cpython 3.12–3.14 and nothing else, so that set does
not resolve elsewhere and is not expected to.

Scorecard — measured, not assumed

Real scanner (gcr.io/openssf/scorecard:stable) against this working tree,
re-run after the action bumps to prove they did not regress the pinning:

Aggregate score: 10.0 / 10
| 10 / 10 | Fuzzing             | project is fuzzed           | ClusterFuzzLite integration found;
|         |                     |                             | PythonAtherisFuzzer: fuzz/fuzz_source_path.py:62,
|         |                     |                             | fuzz/fuzz_yaml_frontmatter.py:73
| 10 / 10 | Pinned-Dependencies | all dependencies are pinned | 43/43 GH actions, 18/18 third-party,
|         |                     |                             | 6/6 containerImage, 22/22 pipCommand,
|         |                     |                             | 1/1 npmCommand

Both checks close only at max score, and both are at max. The PR's own added
lines were also swept against the checker's unpinned-install predicates: every
pip install added by this diff carries --require-hashes, or -e plus
--no-deps; no new curl | sh, no unversioned npm install.

Gates run on the merged tree

Gate Result
ruff format --check . 1067 files already formatted
ruff check . All checks passed!
python -m pyright mcp_server/ 0 errors, 0 warnings, 0 informations
pytest 6421 passed, 5 skipped, 117 subtests passed in 653.86s
generate_pip_constraints.py --check requirements OK (13 checked)
check_doc_claims.py --test-count 6426 doc claims OK
generate_repo_badges.py --check --test-count 6426 badges OK (5 checked)

The pyright run is quoted from a venv built exactly as CI builds it
(ci-typecheck.txt + -e . --no-deps + typecheck-tool.txt). A first attempt
using the test env reported 4 reportMissingImports errors on
mcp_server/infrastructure/otel_exporter.py — that env simply lacks the [otel]
extra, and an unresolved import makes a type-checker report fewer real errors,
not more. Quoted only after the environment could resolve the imports.

Still measured by CI rather than locally, and why

Stated plainly rather than implied green:

  1. The amd64 container builds are CI's, not this machine's. Docker here is
    Colima on arm64 with no buildx, so --platform linux/amd64 is not a
    cross-build: the legacy builder installs aarch64 toolchains and then fails
    the platform assertion. The root Dockerfile builds clean natively
    (Successfully tagged cortex:pin-check), and the authoritative amd64
    evidence is this PR's own green Docker Build (runtime image), Docker
    Build (devcontainer image)
    and Docker Smoke jobs.
  2. The fuzzers themselves do not run here. Atheris has no macOS/aarch64
    wheel. The harness properties run locally through the corpus replayer inside
    the ordinary pytest suite; the coverage-guided campaign is the green
    Fuzz (PR batch) jobs.
  3. Alert closure is only provable on main after merge. Scorecard's alerts
    are written by the scorecard.yml run on the default branch, so the 22 → 0
    count must be read there, never off this PR ref. The 10/10 above is the
    scanner's verdict on this tree, which is the strongest pre-merge evidence
    available.

Nothing deferred

No un-issued deferral remains. The Node 20 deprecation — the one item the
previous body carried as "unrelated, pre-existing" without an issue number — is
fixed in this PR: all 16 pinned action refs now sweep to 0 on node20, each
still SHA-pinned and each verified by reading runs.using out of its
action.yml at the target SHA rather than trusting the tag. codeql-action
needed the v4 line, since v3.37.3 is still node20.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u

cdeust and others added 6 commits July 28, 2026 23:46
Closes the 22 open OpenSSF Scorecard alerts: 21 Pinned-Dependencies and the
Fuzzing check. No Dependabot/CVE alerts were open.

Pinning
-------
An exact version is not a pin. `foo==1.2.3` still resolves to whatever the
index serves under that version today; only a hash pins the bytes, which is
what Scorecard's check encodes. `--require-hashes` is all-or-nothing, so it
needs a resolved lock — uv.lock becomes that single source of truth:

* pyproject.toml gains [dependency-groups] for the tools each job runs
  (ruff, pyright, build+hatchling, atheris) so they are locked rather than
  restated as bare version pins in two workflow files.
* [[tool.uv.index]] declares the PyTorch CPU index and [tool.uv.sources]
  binds torch to it on Linux. The three containers previously passed
  --index-url at the call site, so the lock described PyPI's artifact while
  the image installed a different one — no source of truth could produce a
  hash for what was actually installed. The lock now records torch
  2.13.0+cpu, and resolution drops 18 nvidia/cuda packages plus triton.
  torch is named in a `container` dependency-group only so the source can
  bind to it; PEP 735 groups are not published, so nothing changes for
  anyone installing hypermnesia-mcp from PyPI.
* scripts/generate_pip_constraints.py exports one hashed file per call site
  into requirements/ and refuses an export that is empty or carries an
  unhashed requirement. `--check` is a blocking Lint step, so a lock change
  that is not re-exported fails there rather than at install time.
* All 21 sites rewired: ci.yml x7, release.yml x2, setup.sh, and the three
  Dockerfiles. The project itself installs with --no-deps against the hashed
  set; the root image builds a wheel instead, because an editable install
  leaves a .pth pointing at a build directory the runtime stage never copies.

The two non-pip findings could not be pinned and had to stop being what they
were:

* docker/Dockerfile piped https://deb.nodesource.com/setup_22.x into bash —
  an unreviewed remote script executed as root at build time, with no hash
  to check a pipe against. Replaced by what that script does: fetch the
  signing key, register the signed apt source, install the signed package.
* `npm install -g @anthropic-ai/claude-code` was unversioned, so the image
  tracked whatever the registry served that minute. Now `npm ci` against a
  committed lockfile, which is also the only form Scorecard accepts and
  which records a sha512 integrity hash per transitive package.

Fuzzing
-------
Two harnesses over pure parsers that read untrusted text: the hand-rolled
YAML frontmatter parser, and the wiki source-path canonicaliser. Wired to
ClusterFuzzLite (.clusterfuzzlite/, .github/workflows/fuzz.yml) — a short
batch on PRs, a longer scheduled run that does not block.

Writing the path harness found a live bug. normalize_source_path stripped
"./" in a loop and then "/" once, so removing the slashes could expose a
"./" the loop had already walked past: ".//./x" came out as "./x", still
carrying the prefix the function exists to remove, and not idempotent.
extract_document_paths dedupes on that result, so one document reachable by
two spellings counted as two. Fixed by iterating to a fixed point; the four
reproducers are committed as corpus inputs and fail on the pre-fix code.

fuzz/replay_corpus.py runs every corpus input through its harness with no
atheris, so the properties execute in the ordinary pytest suite on every
platform — atheris publishes manylinux x86_64 wheels for cpython 3.12-3.14
and nothing else, and a property only one CI job can run is one that rots.

Also fixed here
---------------
* docker/Dockerfile copied /usr/local/lib/python3.12/site-packages against a
  python:3.14 base — a path absent from both stages, so that image could not
  build at all. Invisible because no CI job builds it; both it and the
  devcontainer image now get build jobs, which is also the evidence #203
  asks for and closes the gap its own note flags.
* scripts/setup.sh hid pip's stderr behind 2>/dev/null and printed success
  regardless — a resolution failure, a hash mismatch and a network error all
  reported "Python packages installed". Its hand-written package list had
  also drifted from pyproject.toml (sentence-transformers>=2.2.0 against a
  real floor of >=3.0.0).
* .gitattributes marks fuzz/corpus/** as binary: EOL normalisation would
  have rewritten the CRLF seed and deleted the case it exists to cover.

Refs #203

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw
The docker/ and .devcontainer/ images had no CI build, which is why
docker/Dockerfile could sit unbuildable against a python:3.14 base. Both
now build on every push and PR — that is also the evidence #203 asks for,
and it closes the gap that issue's own note flags rather than deferring it
to a follow-up.

Advertised suite size synced 6348 -> 6383 across 11 sites in 5 files (35
new tests: constraint-set mapping, fuzz corpus replay, path canonicalisation
regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw
…mmand

The lock-drift gate added in 570d4b3 installed its own tool with
`pipx install uv==0.11.3 2>/dev/null || pip install uv==0.11.3`. Scorecard
scores that fallback as "pipCommand not pinned by hash" — the same finding
already open on `pip install pyright==1.1.410` (alert 142) — because an
exact version is not a pin: `==` resolves to whatever bytes the index
serves under that version today.

So the commit that closes the other 21 Pinned-Dependencies alerts would
have minted a 22nd, in a new file of its own making. That is not a partial
win: the repo's policy clears PinnedDependenciesID only at `score: 10,
mode: enforced`, so one unpinned command leaves all 21 open.

uv cannot come from a hash-pinned requirements file — it is the tool that
generates those files. It comes from the SHA-pinned action instead, the
form release.yml's SBOM job already uses, with `version` preserving the
0.11.3 pin so `--check`'s byte comparison stays deterministic.

Verified: `generate_pip_constraints.py --check` exits 0 (13 files),
`check_doc_claims.py` exits 0, ci.yml parses, and no unpinned pip/npm
command remains at any of the 23 install sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RahDoFS4HWmqPzYghhYFar
Root cause, not the symptom. `render` located uv, ran it, and judged its
output in one function, so the `shutil.which("uv")` guard ran before every
rule. No test could reach the validation logic without a real uv on PATH —
stubbing subprocess.run did not help, because the guard fired first. On CI,
where uv is only in the Lint job, four tests failed with "uv is not
installed" while asserting nothing about hashing or drift.

The symptomatic fix was to install uv into all three test jobs. That would
have made red jobs green while leaving unit tests dependent on an external
binary they never needed.

Split instead:
  * export()  — locates and runs uv. The only part that touches the world.
  * compose() — judges the export and assembles the file. Pure.
  * render()  — compose(set, export(set)).

The rules are now reachable from a string literal, so the hashing and
empty-export tests need no uv, no stub and no PATH.

TestDriftGate now exercises the gate's DECISION (compare committed text to
what the lock would produce; map that to an exit code) rather than the
working tree's current state. Whether the committed files actually agree
with uv.lock is a fact about the tree, already asserted by the Lint job's
`--check` on every push and PR. Asserting it inside pytest too did not make
it truer; it made every test job carry uv. One gate, where it belongs.

Verified with uv removed from PATH entirely: 247 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw
The lock was STALE, not constrained. That is the whole root cause.

Exporting it pinned tree-sitter-language-pack 1.6.2 while CI's previous
`pip install -e .[...]` fresh-resolved 1.13.5, and pyright failed on the
older package's stricter `SupportedLanguage` signature. Measured against a
real 3.12 resolve, the exported lock disagreed on 66 of 120 packages.

Two wrong answers were tried and reverted before this one:
  * a `cast` at the pyright call site — masking one visible symptom of a
    graph-wide version skew;
  * switching the generator to `uv pip compile --python-version`, which
    resolves FRESH FROM THE INDEX and does not read uv.lock at all. That
    made the files non-deterministic, broke the "generated from uv.lock"
    contract, and would have made Dependabot's uv.lock updates decorative —
    a security regression, since Dependabot is what keeps these pins from
    freezing on vulnerable versions.

`uv lock --upgrade` was the fix. tree-sitter-language-pack moves 1.6.2 ->
1.13.5 and the divergence from a fresh 3.12 resolve drops 66 -> 10. The
remaining 10 (numpy, scipy, scikit-learn, transformers, huggingface-hub,
onnxruntime, networkx, fsspec, rpds-py, aiofile) are the `requires-python
= ">=3.10"` floor: their newer releases dropped 3.10, so a 3.10 install
gets exactly these. That is a property of the supported range, not of this
change, and Dependabot raises security PRs against uv.lock when one of them
needs moving.

The generator stays lock-derived, so a Dependabot bump to uv.lock flows
into every requirements file through `--check`.

Full suite: 6389 passed. Generation is deterministic across repeated runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw
#243 landed the self-hosted README badges, which conflicts with this
branch's shields.io badge row and with the advertised test count.

Resolutions:
  * README badge row — main's committed assets/*.svg supersede this
    branch's shields.io markup outright.
  * ci.yml Lint job — BOTH gates kept. main added the committed-badge
    check, this branch added the hash-pinned-requirements check; neither
    supersedes the other.
  * Test count — recomputed on the merged tree rather than picking a side:
    6414 collected, synced across 11 sites in 5 files, and
    assets/badge-tests.svg regenerated so the doc-claims gate reads the
    same figure it asserts.
  * mcp_server/core/ast_parser.py — restored to main byte-for-byte. The
    `cast("SupportedLanguage", ...)` this branch carried was left over from
    an abandoned fix and had lost its type-only import (ruff F821). It is
    also moot: the refreshed lock ships tree-sitter-language-pack 1.13.5,
    whose signature main already passes pyright against. Zero divergence.

Fixed en route (§14), pre-existing and unrelated to this branch:
tests_py/invariants/test_I2_canonical_writer.py excluded scan paths by
testing the ABSOLUTE path for "worktree". An agent worktree lives at
<repo>/.claude/worktrees/<name>/, so running the suite from one made every
path match and skipped the entire package — the scan found zero heat_base
writers, `unexpected` was empty, and the invariant passed VACUOUSLY. It
could not have caught a new unauthorized writer from a worktree at all;
only the stale-allow-list half of the assertion made it visible. Both scan
loops now test the path relative to the scan root. Verified from the
worktree (52 passed) and from the primary checkout (3 passed).

Full suite on the merged tree: 6414 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw
@cdeust

cdeust commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Heads-up from #248: ci.yml's Node20 actions were bumped to their Node24 majors on main (checkout 3d3c42e5 v7.0.1, cache 55cc8345 v6.1.0, upload-artifact 043fb46d v7.0.1, build-push-action 53b7df96 v7.3.0). This branch's fuzz.yml still carries the Node20 pins and will emit the deprecation warning — please adopt the same four SHAs there when resolving this PR's conflicts. Tracked in #246.

cdeust and others added 7 commits July 29, 2026 14:19
Brings f606c37 (marketplace-pin guard on the root manifest.json). The six
conflicts were all one claim — the advertised collected-test count — where
main had moved 6373 -> 6376 and this branch 6373 -> 6414. Resolved to the
branch's number here; the merged tree's real count is measured and applied
in a later commit, once the tests that move it exist.

Repairs a defect found while resolving: .bestpractices.json was committed at
c090278 carrying FOUR unresolved conflict blocks from the previous merge,
which left it invalid JSON. Both sides of all four were byte-identical, so
collapsing them is lossless (verified by comparing the sides before
collapsing, not by picking one). The file is transcribed into the OpenSSF
Best Practices questionnaire, so an unparseable copy is a broken consumer
and not merely a stale number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…had no cp310 artifact

`Test (Python 3.10)` failed at install with

    ERROR: Could not find a version that satisfies the requirement
           onnxruntime==1.24.3 (from versions: ..., 1.23.0, 1.23.1, 1.23.2)
    ERROR: No matching distribution found for onnxruntime==1.24.3

(CI run 30436365825 job 90524943669; reproduced 2026-07-29 in
python:3.10-slim/linux-amd64 against requirements/ci-postgresql.txt.)

Root cause is the lockfile, not the workflow. onnxruntime 1.24.x publishes
no cp310 artifact and no sdist — 1.24.3 ships 24 files whose lowest
interpreter tag is cp311, and 1.24.0 declares no Requires-Python at all, so
uv accepted it for the `python_full_version < '3.11'` fork and recorded it
there with a wheel list containing nothing installable on 3.10. A lock entry
for the 3.10 fork with no 3.10 artifact is broken by inspection.

The defect is pre-existing on main (`git show origin/main:uv.lock` carries
the same entry) and stayed latent only because main installs from
pyproject.toml, where pip re-resolves onnxruntime down to 1.23.2 itself.
This branch makes the lock the install source, which turns the latent lock
defect into a hard failure. Fixed at the lock, not at the throw site.

A `[tool.uv] constraint-dependencies` entry, not a project dependency:
constraints steer only our resolution and are never published in the wheel
metadata, so consumers on 3.11+ still get the current onnxruntime. Declared
before the `[[tool.uv.index]]` / `[tool.uv.sources]` tables so it never
re-opens a `tool.uv` super-table they created implicitly.

Verified across every (requirements file, python) pair its consumers use,
on linux/amd64 (CI's platform; this host is arm64) with
`pip install --dry-run --require-hashes`. onnxruntime was the only package
with this defect: every other pin resolves unchanged.

Lock delta: onnxruntime 1.24.3 -> 1.23.2 on the <3.11 fork only (1.28.0 on
>=3.11 is untouched), plus its three transitive deps (coloredlogs,
humanfriendly, pyreadline3). 9 of 13 requirements files regenerated with
uv 0.11.3 — the exact version .github/workflows/ci.yml pins via
astral-sh/setup-uv, because the Lint gate byte-compares a `uv export`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…JSON

The four unresolved conflict blocks repaired in the merge commit had passed
this gate, CodeQL, and 18 green checks. .bestpractices.json is one of the
gate's own SCANNED_FILES, so the gate READ the broken file and reported
"doc claims OK" — every check it runs is a claim regex, and a regex matches
the first side of a conflict and never looks at the file's structure. That
is the §6 root cause: the gate had no notion of a file that says two things.

Two checks close it, both derived from SCANNED_FILES so a file added to the
gate is enrolled in them with no further edit (§1.2):

  * check_no_conflict_markers — matches only the LABELLED markers
    (`<<<<<<< HEAD`, `>>>>>>> origin/main`; git always writes a ref after the
    seven characters). A bare `=======` is deliberately not matched: it is a
    legal setext H1 underline in Markdown and most scanned files are
    Markdown, so matching it would fail honest documents. Pinned by
    test_a_markdown_setext_underline_is_not_a_conflict_marker.
  * check_scanned_json_parses — the OpenSSF answers are transcribed into the
    badge questionnaire and manifest.json is read by the plugin loader, so a
    file that no longer parses is a broken consumer, not a stale number.

Both fail closed on a missing file, matching check_badge's rule that a check
which silently skips its subject is worse than none because it still prints
OK.

Non-vacuity, measured rather than asserted: against the pre-fix
.bestpractices.json (git show c090278:.bestpractices.json),
test_the_real_repository_tree_is_structurally_clean fails with all 8 marker
lines reported; against the repaired file the 9 new tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
… 6426

Measured, not carried over:

    $ pytest --collect-only -q | tail -1
    6426 tests collected in 9.42s

(python 3.12, the leg CI runs this check on.) The arithmetic reconciles:
6414 on this branch + 3 from main's f606c37 (marketplace-pin guard tests)
+ 9 from the doc-claim structural gate = 6426.

Updates the five claim sites the gate scans and regenerates
assets/badge-tests.svg, which carries the same figure in its <title>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
Formatting only, no behaviour change; kept off the logic commit per §H5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…ker gate

Both belong to the #244 entry: the lock defect is what making the lock the
install source exposed, and the conflict-marker gate is the root-cause fix
for a defect this branch itself carried through a green CI run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
Every CI job on this PR emitted, per run:

    ##[warning]Node.js 20 is deprecated. The following actions target
    Node.js 20 but are being forced to run on Node.js 24:
    actions/checkout@11d5960a, actions/upload-artifact@ea165f8d

(CI run 30436365825, all 12 jobs). The runner is ALREADY forcing these onto
Node 24, so the repository was relying on a compatibility shim GitHub has
announced it will remove — the actions were running on a runtime they were
never built against.

The PR body previously reported this as "unrelated, pre-existing" with no
issue number, which is precisely the deferral §14.3 forbids. These refs live
in .github/workflows/ci.yml, a file this diff modifies, so §14.1 puts them in
touched material: fixed here rather than filed.

Bumped, each SHA-pinned as before and each verified node24 by reading
`runs.using` out of its action.yml at the target SHA (not by trusting the
tag):

    actions/checkout          v4    -> v7.0.1   (6 workflows)
    actions/cache             v4    -> v6.1.0   (2 workflows)
    actions/upload-artifact   v4    -> v7.0.1   (2 workflows)
    actions/download-artifact v4    -> v8.0.1   (release.yml)
    docker/build-push-action  v6    -> v7.3.0   (ci.yml)
    github/codeql-action      v3    -> v4.37.3  (scorecard.yml)

upload-artifact and download-artifact move together because release.yml
pairs them; download-artifact was node20 as well, so leaving it would have
kept the same defect and mismatched the pair.

codeql-action needed the v4 line, not the newest v3: v3.37.3 is still
node20. Checked rather than assumed.

A repo-wide sweep of all 16 pinned action refs now reports **0 on node20**
(the rest are `docker` or `composite` actions, which have no node runtime).
Scorecard re-measured on this tree after the bumps: Pinned-Dependencies
10/10 (43/43 GH actions, 18/18 third-party, 6/6 containerImage, 22/22
pipCommand, 1/1 npmCommand) and Fuzzing 10/10 — SHA pinning is preserved,
so the alert closure this PR exists for is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
@cdeust
cdeust merged commit 3c56eb1 into main Jul 29, 2026
20 checks passed
@cdeust
cdeust deleted the sec/pin-dependencies-and-fuzzing branch July 29, 2026 16:05
cdeust added a commit that referenced this pull request Jul 29, 2026
* ci: add a safe workflow_dispatch verification path to release.yml (#246)

Every uses: in .github/workflows/ already resolved to actions/checkout
v7.0.1, actions/cache v6.1.0, actions/upload-artifact v7.0.1,
actions/download-artifact v8.0.1, and github/codeql-action/upload-sarif
v4.37.3 (all node24, confirmed by reading action.yml at each pinned SHA) —
PR #244 bumped these SHAs to their current majors as a side effect of its
hash-pinning pass, ahead of this issue landing. Nothing left to bump.

What remained was #246's AC4: one dispatch-verified green run of each
affected workflow with zero "forced to run on Node.js 24" lines, quoted
in the PR — and AC3: the upload-artifact/download-artifact round trip in
release.yml proven by one real run. release.yml is push-tag-only, so
proving that round trip meant either cutting a real release (out of
scope: it would publish a real PyPI version and GitHub Release for a CI
verification) or adding the seam to test it safely.

Add workflow_dispatch to release.yml, gated so a manual run exercises
checkout/cache/upload-artifact/download-artifact exactly as a real
release would, but never creates a GitHub Release, never attaches
assets to one, and never publishes to PyPI:
- github-release job: if: github.event_name == 'push'
- build job's "Attach attested distributions" step: same guard
  (softprops/action-gh-release creates the tag_name release if absent —
  an unguarded dispatch run would forge one)
- sbom job's "Upload SBOM" step: same guard
- publish-pypi job: only the pypa/gh-action-pypi-publish step is guarded;
  the download-artifact step above it stays unconditional, since that is
  the exact half of the round trip this exists to verify

Refs #246.

* ci(release): bump actions/attest-build-provenance v2.4.0 -> v4.1.1 (#246)

Real dispatch-verification run (30498168023) caught what #246's own
investigation missed: this action's `runs.using: composite` at v2.4.0
still shells out internally to `attest-build-provenance/predicate@1176ef5`
and an older `actions/attest`, both node20 -- a real
"forced to run on Node.js 24" warning fired twice in that run (sbom job,
build job), on an action #246 had listed as needing no change.

v3.0.0's release notes (actions/attest-build-provenance#691, #693) name
the fix directly: "Bump actions/attest ... Bump to node24 runtime" +
"Bump attest-build-provenance/predicate to v2.0.0 ... Bump to node24
runtime". v4.0.0 restructures further: the composite step now calls
actions/attest@v4.1.1 (SHA a1948c3f..., confirmed node24 by reading its
action.yml) directly, dropping the separate predicate step. Inputs are
unchanged (subject-path/subject-digest/subject-name/... identical names
in v4.1.1's action.yml vs our v2 usage) -- no call-site change needed.

Refs #246.
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.

sec: hash-pin the pip/npm/download-then-run steps in the three Dockerfiles (Scorecard Pinned-Dependencies)

1 participant