Phase 3 (O.1-O.7): scan controller, trigger policy, and seal provenance - #9
Merged
Conversation
The scan controller state machine, the deadline clocks, the trigger-policy engine, the handoff adapter, and semver matching. Plus the O.4 critic gate and its fix round -- which turned into the most consequential change in the project so far. O.4 returned FAIL: two blockers, six majors, four minors. A documented limit came true within hours Two rounds ago an adversary attacked the per-half read gate sixteen ways. Attack 14 was: CALL the gate, but against a HalfSeal you fabricated or held stale, then return the results anyway. I ruled it out of scope for static analysis -- correctly, it is a dataflow question, not a reachability one -- and had it DOCUMENTED in readpath_test.go's KNOWN LIMITS instead, along with the runtime fix that would actually close it. O.4 then found that exact attack occurring NATURALLY in the first area written after the ruling. AuditRecord.HalfSeal built a record.HalfSeal from caller-held fields with no refresh path and handed it to the gate, which answered truthfully about a seal that could be arbitrarily stale. It was not adversarial. It is simply the natural way to write it. That is enough evidence to change the call, so the runtime fix is now in. HalfSeal carries an unexported provenance marker set by exactly two producers. Outside internal/record an unexported field cannot appear in a composite literal, so it is unforgeable by the COMPILER rather than by convention, and an AST test fails if a third function inside the package ever stamps it. Crucially the marker carries enough to detect STALENESS and not merely construction -- the audit publishes an atomic facts snapshot on every mutation and the gate re-reads the origin on every call -- because a legitimately minted seal held across a state change was the actual defect. Verified adversarially, and the fix is measured load-bearing: neutering it turns six named tests red. Zero value REFUSED. Hand-built literal REFUSED. Field-by-field copy of a genuine seal REFUSED. Upgrade attack (keep the real provenance pointer, edit the exported fields) REFUSED as tampered. All four staleness mutations caught. A fresh seal after each transition is still accepted, so the gate is not welded shut. No hand-built HalfSeal remains anywhere in the tree. Attack 15 REMAINS OPEN and the documentation says so plainly -- a legitimate SAST seal still opens the gate while the DAST half is unsealed. It was probed and confirmed rather than assumed. A denial of service on the scanner itself Policy glob matching was super-polynomial and unbounded, and the pattern comes from .anvil/policy.yml -- supplied by the repository under scan. Anvil scans untrusted repositories by design, so this was attacker-controlled input reaching an exponential matcher. The critic's own shape (30 segments, ten wildcards) took 8.51 seconds; it now takes zero. The first fix bounded each pattern but not the aggregate, which left the same outage reachable by MULTIPLICATION: 256 rules x 2 path keys x 64 patterns x 4096 paths is 134 million match calls, between 23 seconds and 9 minutes per event. Four bounds now, and the fourth is the one that closes it -- the engine computes the worst-case call count from the policy's SHAPE in O(rules) arithmetic before the first match and refuses over budget. Enforcement lives in the engine at three entry points, not only in the JSON Schema, because a policy can reach Evaluate without passing through either the schema or the decoder. Refusal, never truncation. A silently truncated policy is the worst outcome, because the operator believes rules are in force that are not -- and a test asserts a policy exactly at the cap applies ALL of its rules. The rest of O.4, and one class closed rather than two instances Fixed: clock 3 had no authoritative substrate and moved by field assignment; a redelivered seal event bumped audit_version although the Sealer treated it as an idempotent no-op (a bump re-cuts the queue, so duplicates silently re-cut); applyFindings and applyCorrelation guarded on stale caller state so findings landed on expired audits; concurrent fan-in lost findings while the doc claimed it only skipped a version bump. Two mutation paths in sealing.go skipped publish() -- in the one file whose staleness guarantee depends on publishing on every mutation. That leaves a stale seal reading as CURRENT, which is not a degraded check but the check silently inverted, on exactly the path it was built to defend. Both are fixed, and the CLASS is closed with a mutation funnel plus an AST test over every fact-bearing assignment, because this project has now paid four separate times for fixing instances of a class instead of the class. Every new guard was verified RED against the pre-fix code, not merely green against the fixed code. Also here: O.1 ruled the deadline anchor is scan_run.started_at and NOT created_at as its own packet text said, because created_at is a write timestamp and anchoring to it is a weaker form of what R.6 already forbids. The frozen schema uses that spelling. I agree with the ruling; recording it because it contradicts the packet. Evidence: gofmt, vet, build clean; go test -count=1 ./... green across cmd/anvil, internal/handoff, internal/policy, internal/record, internal/scanctl, internal/store. NOT VERIFIED HERE: go test -race cannot run on this Windows host (cgo.exe exits 2, no C toolchain). Every concurrency conclusion in this batch -- and one of the majors was concurrent fan-in losing findings -- rests on CI's Linux run. CI has already caught one real concurrency bug on this project that passed locally.
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.
Summary
The scan controller state machine, deadline clocks, trigger-policy engine, handoff adapter and semver matching — the two-thirds of Phase 3 that isn't gated on
M0.18. Plus theO.4critic gate and its fix round, which turned into the most consequential change in the project so far.O.4returned FAIL: 2 blockers, 6 majors, 4 minors.A documented limit came true within hours
Two rounds ago an adversary attacked the per-half read gate sixteen ways. Attack 14 was: call the gate, but against a
HalfSealyou fabricated or held stale, then return the results anyway. I ruled it out of scope for static analysis — correctly; it's a dataflow question, not a reachability one — and had it documented inreadpath_test.go's KNOWN LIMITS instead, along with the runtime fix that would actually close it.O.4then found that exact attack occurring naturally in the first area written after the ruling.AuditRecord.HalfSealbuilt a seal from caller-held fields with no refresh path and handed it to the gate, which answered truthfully about a seal that could be arbitrarily stale. It wasn't adversarial — it's simply the natural way to write it.That's enough evidence to change the call, so the runtime fix is in:
HalfSealnow carries an unexported provenance marker set by exactly two producers. Outsideinternal/recordan unexported field cannot appear in a composite literal, so it's unforgeable by the compiler rather than by convention — and an AST test fails if a third function inside the package ever stamps it. Crucially the marker detects staleness, not merely construction: the audit publishes an atomic facts snapshot on every mutation and the gate re-reads the origin on every call, because a legitimately-minted seal held across a state change was the defect.prov, edit exported fieldsNeutering the mechanism turns six named tests red, so the fix is measured load-bearing rather than incidentally green. No hand-built
HalfSealremains anywhere in the tree.Attack 15 remains open and the docs say so plainly — a legitimate SAST seal still opens the gate while the DAST half is unsealed. Probed and confirmed, not assumed.
A denial of service on the scanner itself
Policy glob matching was super-polynomial and unbounded, and the pattern comes from
.anvil/policy.yml— supplied by the repository under scan. Anvil scans untrusted repos by design, so this was attacker-controlled input reaching an exponential matcher. The critic's own shape (30 segments, ten wildcards) took 8.51 s; it now takes 0 s.The first fix bounded each pattern but not the aggregate, leaving the same outage reachable by multiplication:
256 rules × 2 path keys × 64 patterns × 4096 paths= 134 million match calls, 23 s to 9 min per event. Four bounds now, and the fourth is the one that closes it — the engine computes worst-case call count from the policy's shape inO(rules)arithmetic before the first match and refuses over budget.Enforcement is in the engine at three entry points, not just the JSON Schema, because a policy can reach
Evaluatewithout passing through either. Refusal, never truncation — a silently truncated policy is the worst outcome, since the operator believes rules are in force that aren't. A test asserts a policy exactly at the cap applies all its rules.One class closed rather than two instances
Two mutation paths in
sealing.goskippedpublish()— in the one file whose staleness guarantee depends on publishing on every mutation. That leaves a stale seal reading as current: not a degraded check but the check silently inverted, on exactly the path it was built to defend.Both fixed, and the class closed with a mutation funnel plus an AST test over every fact-bearing assignment — because this project has now paid four separate times for fixing instances of a class instead of the class.
Also fixed: clock 3 had no authoritative substrate; a redelivered seal event bumped
audit_versiondespite the Sealer treating it as an idempotent no-op (a bump re-cuts the queue, so duplicates silently re-cut);applyFindings/applyCorrelationguarded on stale caller state so findings landed on expired audits; concurrent fan-in lost findings while the doc claimed it only skipped a version bump.Every new guard was verified RED against the pre-fix code, not merely green against the fixed code.
One ruling that contradicts its own packet
O.1ruled the deadline anchor isscan_run.started_at, notcreated_atas its packet text said —created_atis a write timestamp, and anchoring to it is a weaker form of whatR.6already forbids. The frozen schema uses that spelling. I agree; flagging it because it contradicts the packet.Testing
Not verified here:
go test -racecannot run on this Windows host. Every concurrency conclusion in this batch — and one of the majors was concurrent fan-in losing findings — rests on CI's Linux run, which has already caught one real concurrency bug on this project that passed locally. This PR's CI run is the actual check on that.