sec: hash-pin every dependency install and add coverage-guided fuzzing - #244
Open
cdeust wants to merge 2 commits into
Open
sec: hash-pin every dependency install and add coverage-guided fuzzing#244cdeust wants to merge 2 commits into
cdeust wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Refs #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.20andtorch==2.11.0both 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-hashesis all-or-nothing — one hash means every requirement including transitive ones needs one — so it needs a resolved lock.uv.lockbecomes the single source of truth.scripts/generate_pip_constraints.pyrequirements/. Refuses an export that is empty or carries an unhashed requirement.--checkis a blocking Lint step, so a lock change that isn't re-exported fails there, not at install time.[dependency-groups][[tool.uv.index]]+[tool.uv.sources]The torch finding was worse than "unpinned"
The containers passed
--index-url https://download.pytorch.org/whl/cpuat the call site. Souv.lockrecorded 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-hasheswasn't reachable before.The lock now carries
torch 2.13.0+cpuwith 22 hashes, and resolution drops 18 nvidia/cuda packages plus triton. torch is named in acontainerdependency-group only so the source can bind to it — PEP 735 groups aren't published, so nothing changes for anyone installinghypermnesia-mcpfrom 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 feedsgpg --dearmor, which executes nothing.npm install -g @anthropic-ai/claude-code— unversioned, so the image tracked whatever the registry served that minute. Nownpm ciagainst 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
isUnpinnedPipInstallrather than inferring it. A barepip install --no-deps .is unpinned —.is neither a flag nor a.whl, so it setshasAdditionalArgs;isPinnedEditableSourceis consulted only for-e. Hence:--require-hashes, or-e <local> --no-deps, or a.whlpath. The root image therefore builds a wheel — an editable install there would leave a.pthpointing 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_pathstripped./in a loop, then/exactly once. Removing the slashes can expose a./the loop already walked past:The result was not idempotent, and
extract_document_pathsdedupes 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.pyreplays every corpus input with no atheris, so the properties run in the ordinarypytestsuite 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/Dockerfilecould not build at all — it copied/usr/local/lib/python3.12/site-packagesagainst apython:3.14base, 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.shreported success over any failure — the install ended in2>/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..gitattributesmarksfuzz/corpus/**binary — EOL normalisation would have rewritten the CRLF seed on checkout and deleted the case it exists to cover.Completion Ledger (§13.2)
Path enumeration
named_index_url— foundtest_every_local_version_pin_names_a_serving_indexnamed_index_url— absent →ExportErrortest_undeclared_index_is_refusedConstraintSet.command()— extras / groups / only-groupstest_export_asserts_the_lock_matches_pyprojectheader()— with and without index directivetest_every_local_version_pin_names_a_serving_indexrender()— uv missing →ExportErrortest_export_failure_exits_two_not_onerender()— uv non-zero exit →ExportErrortest_export_failure_exits_two_not_onerender()— empty export refusedtest_empty_export_is_refusedrender()— unhashed requirement refusedtest_unhashed_export_is_refusedrender()— local pin absent from locktest_local_pin_absent_from_the_lock_is_refusedwrite()— changed / unchangedtest_check_passes_on_the_committed_treestale()— missing filetest_check_fails_when_a_file_is_missingstale()— content differstest_check_fails_on_a_mutated_filestale()— current → Nonetest_check_passes_on_the_committed_treemain()—--checkclean → 0 / drift → 1 / cannot run → 2test_check_passes_on_the_committed_tree,test_check_fails_on_a_mutated_file,test_export_failure_exits_two_not_onetest_drift_message_names_the_regeneration_commandtest_every_set_has_a_committed_file,test_no_committed_file_is_orphaned,test_filenames_are_uniquetest_every_requirement_is_version_pinned_and_hashedtest_no_editable_or_directory_requirementsnormalize_source_path— canonical /.//// backslash / blank / separators-onlyTestNormalizeSourcePath(6 tests)normalize_source_path— fixed-point regressionsTestNormalizeSourcePathReachesAFixedPoint(4 + idempotence)normalize_source_path—..left intact (negative)test_parent_traversal_is_left_intactextract_document_paths— two spellings collapsetest_two_spellings_of_one_document_collapse_to_onetest_at_least_one_harness_existstest_harness_holds_on_its_whole_corpusfuzz_yaml_frontmatter.consumeover 10 corpus inputs/, no leading./, non-empty, idempotentfuzz_source_path.consumeover 10 corpus inputs§13.1 checklist
A. Correctness & behavior
6383 passed, 97 subtests passed in 200.81s.--require-hashesinstall proven end-to-end (ruff 0.15.20installed fromrequirements/lint.txt, exit 0).--require-hashesis 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.--checknever writes; generation idempotent;normalize_source_pathidempotent (the property the bug violated).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
read_bytes. Image size falls — 18 nvidia/cuda packages + triton removed from every container.D. Security — this is the point of the change
subprocess.runuses a fixed argv, no shell, no user input (# noqa: S603with that rationale).permissions: read-allon 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-urladded 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
requirements/, new groups. No public API change; PEP 735 groups are not published, so PyPI consumers see identical metadata.ci.yml(all 7 sites),release.yml(2),scripts/setup.sh,Dockerfile,docker/Dockerfile,.devcontainer/Dockerfile,.clusterfuzzlite/build.sh.extract_document_pathsandtests_py/handlers/test_wiki_page_sources.pyre-verified against the changed normalisation (637 passed)..gitattributeshandles the cross-platform EOL case explicitly. The Windows CI leg installsci-sqlite-min.txt, which resolves without a tree-sitter toolchain by design.F. Observability & operations
run scripts/generate_pip_constraints.py); asserted bytest_drift_message_names_the_regeneration_command. Success printsrequirements OK (13 checked).setup.shno longer swallows pip's stderr.G. Tests
git stash-ing the fix and re-running the replayer (it failed onrepro-backslash-mixed, then passed once restored).test_no_editable_or_directory_requirements,test_no_committed_file_is_orphaned,test_parent_traversal_is_left_intact, corrupted-hash rejection.ruff check .→ All checks passed!;ruff format --check .→ 1064 files already formatted;check_doc_claims.pyandgenerate_pip_constraints.py --checkboth green.H. Code quality & delivery
S603fixed argv;PLC0415atheris has no wheel for macOS/aarch64).--upgrade pip buildremoved from the root image: it was itself an unpinned install andbuildwas never invoked there.check_doc_claims.py,generate_repo_badges.py— same fail-closed--checkidiom).docker/Dockerfile,setup.sh's swallowed stderr and drifted package list, the non-idempotent path canonicaliser, the CRLF-corruptible corpus, andhatchlingbeing fetched unpinned during build isolation. No unfixed, un-issued seen defect remains.What I could not verify locally, and why
Stated plainly rather than implied green:
input/output erroron its own overlayfs. Building them needs many GB. Rather than defer sec: hash-pin the pip/npm/download-then-run steps in the three Dockerfiles (Scorecard Pinned-Dependencies) #203's "built at least once as evidence" criterion, I added CI build jobs fordocker/Dockerfileand.devcontainer/Dockerfile— which is stronger than a one-off local build and closes the "no CI builds either, so regressions are invisible" gap that issue's own note raises. The build evidence will be those jobs on this PR.scorecard.ymlworkflow, or a manual run — and I cannot produce it locally without Docker. This is the one acceptance criterion of sec: hash-pin the pip/npm/download-then-run steps in the three Dockerfiles (Scorecard Pinned-Dependencies) #203 still outstanding, and it is a measurement, not an implementation gap.Unrelated, pre-existing, and still open
Reported, not silently absorbed: every CI job emits
Node.js 20 is deprecatedforactions/checkout,actions/cache,actions/upload-artifactanddocker/build-push-action. All are SHA-pinned, so fixing it means deliberate version bumps across ~6 action refs — a different change from this one, and outside its blast radius. Say the word and I'll do it in a follow-up PR or fold it in here.🤖 Generated with Claude Code
https://claude.ai/code/session_01BJt9KN23rUNQzwoJv4jRWw