fix: verify the build against its own receipt, with the expected mode as an argument (closes #309) #330
Reference in New Issue
Block a user
Delete Branch "issue-309-build-integrity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #309.
Part 1 — the ambient-environment defect
script/verify-buildreadAUTISTMASK_DEBUGout of its own environment and theMakefileinvoked it bare, so the verifier and the compiler agreed with each other about a variable neither of them was told. The expected mode is now the required argument--expect release|debug. There is no default and nothing is read from the environment: a caller that does not say what it built gets a failure, because "no opinion" is not something this can check anything against.make buildrunsenv -u AUTISTMASK_DEBUG script/verify-build --expect release --receipt "$receipt";make build-debugpasses--expect debug.AUTISTMASK_DEBUG=1exported,make buildcompiles a debug bundle and then fails on it, which is the loud outcome; scrubbing it there would silently give the operator something other than what their shell said.README.md's "make buildalways produces a release build" is now true in the only sense that matters: that target never hands back a debug one.Reproduced before and after on this branch, in my own clone:
Part 2 — provenance, and exactly what it proves
build.jsrecords every file it emits, as it emits it, and writes a build receipt: one line per file with its sha256 and whether it is one of the bundles containingsrc/shared/constants.js. TheMakefilecreates that receipt path withmktempper invocation, outside the repo, and removes it in a trap;build.jsrefuses a receipt path insidedist/.dist/is cleared at the start of a build, so it holds only what that build wrote.dist/constants-bundles.txtis gone.verify-build --receipt PATHthen runs three passes, in this order:rootline naming this tree, and every entry parseable, with at least one audited bundle;dist/is walked in full (NUL-delimited,find's status checked) and must contain nothing the receipt does not name, and no symlinks — the build emits none;--expected marker, and no other file may carry one.The ordering is load-bearing: an unwalkable subtree makes every file under it look absent, and "could not look" must never be reported as "was not there".
What this does and does not prove. It proves that
dist/is byte for byte the output of thebuild.jsrun that just finished, with nothing added, removed or altered in between, and that the audited bundles in it compiled to the mode the caller asked for. That is a within-one-make build-invocation guarantee, and it holds because the receipt is a fresh file the build wrote to a path chosen at run time by the process that then verifies against it — not a standing file indist/that whoever rewritesdist/also rewrites.It proves nothing about whether the source tree or
build.jswere honest —build.jsstill cannot vouch forbuild.js, which is why the receipt deliberately records no build mode and the expected mode stays an argument. It proves nothing to anyone handed adist/from elsewhere: without the receipt from its own build there is no input to the check at all. Verifiable provenance for a third party is signing, which is #310's territory and an outward-facing decision, so this makes no claim in that direction and README.md says so in the same words.One consequence worth flagging as a judgement call rather than burying: the standalone
make verify-buildtarget is removed. Re-verifying an existingdist/from a list inside that samedist/is precisely the defect; there is no honest standalone re-check available short of signing, so the target goes rather than continuing to print a green line that means nothing.Test coverage, and the mutations that prove it is not vacuous
script/test-verify-buildwas extended in place, 18 cases to 39. Every previously demonstrated bypass has a case, and all four now fail:autistmask-build-debug=offdist/chrome/src/content/index.js(runs on<all_urls>)dist/chrome/manifest.jsondist/offered against this build's receiptAdded beyond those: extra file under
dist/carrying no marker at all (previously ignored outright), missing--expect, missing--receipt, invalid--expect, unknown argument, receipt insidedist/, receipt with a foreignroot, receipt naming a path outsidedist/or one containing a space, a marker on a file the build did not record as a bundle,AUTISTMASK_DEBUG=1exported while--expect releaseis given (must be ignored, both when it would pass and when it would fail), and fourmake -nread-backs asserting the recipes pass the mode as an argument on a scrubbed environment. The existing failure modes are kept: grep exit-2,find's status, newline and trailing-space paths, symlinkeddist/, and the probe that refuses to count permission cases as coverage when the process is not subject to permissions.Non-vacuity, each mutation applied to the implementation and reverted:
check_entry→ exactly the 4 bypass cases fail, nothing else;check_dist_treefrommain→ the 4 extra-file and 4 symlink cases fail;AUTISTMASK_DEBUGfallback for a missing--expect→ the no---expectcase fails;make buildrecipe at--expect debugwithoutenv -u→ bothmake buildwiring cases fail;manifest.jsonfrom the recorded emissions inbuild.js→ a realmake buildfails withdist/chrome/manifest.json is under dist/ but the build that just ran did not emit it.Verification
make check: green — 39 suites / 811 tests,test-verify-build: 39 case(s) passed, permission cases enabled (runner: direct).script/cibuild: green, and re-run asdocker build --no-cache-filter=check .so the check stage actually executed rather than reportingCACHED. Inside the pinned image the harness runs as root and reportspermission cases: enabled (runner: setpriv, proved against a mode-000 file)— not skipped — andmake buildcompletes there:verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off. Both images built here were removed afterwards; no containers were left behind and no cache was pruned.make build,make build-debugandAUTISTMASK_DEBUG=1 make buildeach run by hand on this branch, with the results above.PASS — #309's definition of done is met; no defects found.
Evidence the checks ran, not cached:
make test-e2e55/55, exit 0;make test-e2e-firefox8/8, exit 0. Both Dockerfiles'RUN make buildexecuted uncached (#11 DONE 3.1s/#14 DONE 2.9s) and themktempreceipt path works in the image build context:verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off. No containers left behind.make checkgreen: 39 suites / 811 tests,test-verify-build: 39 case(s) passed,permission cases: enabled (runner: direct, proved against a mode-000 file). Lint stage#11 [lint 1/1] RUN make lint ... DONE 4.8s(notCACHED), in the pinned container.fmt-checkclean. Repo unmodified by the run.make buildreceipt and all four now exit 1: 26-byte marker-only bundle, tampereddist/chrome/src/content/index.js, tampereddist/chrome/manifest.json, wholesale hand-writtendist/— each a digest mismatch. Also killed: extra file, symlink,dist/chromeswapped for a symlink to a hostile tree, receipt omitting a file that exists indist/, stale-digest receipt with a valid header/root, receipt insidedist/,/dev/null//dev/stdin/ fifo receipts.AUTISTMASK_DEBUG=1 make buildexits 2 onis autistmask-build-debug=on but this build was told to expect autistmask-build-debug=off.--expectand--receiptare both required with no default and nothing read from the environment; missing, repeated, invalid and unknown arguments all fail.SIGINT(exit 130, no leftover underTMPDIR);make -Cfrom another directory works; nothing leaks into the repo ordist/.check_dist_treeremoved, the unwalkable-subtree case reportsthe receipt names dist/chrome/src/content/index.js, which does not exist— "could not look" mis-stated as "was not there", exactly what the ordering prevents.check_dist_treedropped frommain→ exactly the 8 extra-file/symlink cases fail; ambientAUTISTMASK_DEBUGfallback restored → exactly the no---expectcase fails.9f3cc05(check, e2e-chrome, e2e-firefox). Merges cleanly intonext, 1 commit ending(closes #309),TODO.mdincluded, author and committer bothclawbot, no attribution trailers, no non-inclusive terms in the diff.Anomalies, none blocking:
check_dist_treewalks-type f -o -type lonly, so a fifo, socket, device node or empty directory added todist/after the build passes. The exclusion is deliberate and explained in the script (grep on a fifo would hang), and none of those can carry a shippable payload — butREADME.md's "nothing underdist/that the build did not write" is literally broader than what is enforced. Worth a clause if it is ever tightened.script/verify-build --scan-dist-paths PATH ...is reachable from the command line and exits 0 with no output. It is not reachable throughmake buildand grants nothing to an attacker who does not already control the invocation; noted because a silent exit 0 from this script is a shape worth knowing about.README.mdand the script header is accurate — within onemake build,dist/is the output of thebuild.jsrun that just finished; nothing about the source tree's orbuild.js's honesty; nothing to a third party holding adist/, which is #310. Subject only to anomaly 1, it does not overclaim.Disclosure: the
Makefiletrap deletes the receipt, so to run the bypasses against a realdist/I preserved a copy via a temporaryrmshim onPATHin my own clone; the repo tree was never modified by it. The three mutations above were applied with an editor and reverted, and the working tree was confirmed pristine (git statusandgit diffboth empty) withtest-verify-build: 39 case(s) passedafterwards.