diff --git a/internal/policy/engine.go b/internal/policy/engine.go new file mode 100644 index 0000000..2a1a42d --- /dev/null +++ b/internal/policy/engine.go @@ -0,0 +1,1657 @@ +// engine.go is step O.6: the POLICY ENGINE over the schema O.5 froze at +// schemas/policy.schema.json. +// +// The owner's hard constraint (plan/00-SPINE.md S1, restated by +// plan/70-orchestration-ci.md) is that trigger policy is DATA. If a trigger +// decision can only be changed by editing Go, this file has failed no matter +// how clean it reads. So the evaluator below is generic over whatever +// `scanRules` the parsed document contains: it never branches on a particular +// event name, ref glob, path glob, bump kind or cadence. The only closed +// vocabularies it knows are the ones the SCHEMA itself closes -- `depth`, +// `matchSemverBump`, `version` -- plus area 40's `detector` enum, and it knows +// them for VALIDATION at load time, never as a match condition. Grep this file +// for a string literal used to decide whether a rule fires and you will find +// none; the tests in engine_test.go assert that with invented vocabulary. +// +// --------------------------------------------------------------------------- +// THE EVALUATION ORDER, STATED ONCE, NORMATIVELY +// --------------------------------------------------------------------------- +// +// Renovate's `packageRules` convention (research/09 Recommendation 2), which +// schemas/policy.schema.json documents in its `scanRules` description and this +// file implements: +// +// 1. Start from `defaults` (absent `defaults` means every field starts unset; +// the engine has no built-in fallback values of its own). +// 2. Walk `scanRules` in ARRAY ORDER, index 0 upward. Evaluation does NOT +// short-circuit on the first match. +// 3. A rule whose match* keys ALL match contributes its settings. +// 4. A contributing rule overrides earlier layers FIELD BY FIELD. It does not +// replace the whole resolved rule, and a field it leaves unset keeps +// whatever the previous layer put there. +// +// Precedence is therefore, for every leaf field independently: +// +// last matching rule that sets it > earlier matching rule > defaults +// +// "Which rule won" is not an emergent property here: ResolvedRule.Source +// records, per leaf field, the exact layer that set it, and Evaluate never +// ranges over a Go map while resolving. (Ranging a map without sorting keys is +// how the fingerprint work shipped a determinism bug; this file has one map +// range, over the unknown-key set in the decoder, and it sorts.) +// +// --------------------------------------------------------------------------- +// WHAT `failOn` DELIBERATELY IS NOT +// --------------------------------------------------------------------------- +// +// schemas/policy.schema.json flags an OPEN CROSS-AREA ITEM: research/09 writes +// `failOn: high`, while area 40's severity vocabulary is SARIF's +// none|note|warning|error, and the mapping between them "needs one named owner +// before O.6 ships". O.6 does NOT claim that ownership and does not invent the +// mapping. `FailOn` is carried through this engine as an OPAQUE token: merged +// field-by-field like any other setting, never compared, never ordered, never +// mapped. Whoever is named owner of the mapping applies it downstream of the +// resolved rule. Inventing it here would have created exactly the second +// definition that plan/IMPLEMENTATION-PLAN.md section 6 closed ten instances +// of. + +package policy + +import ( + "errors" + "fmt" + "path" + "slices" + "strings" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +var ( + // ErrUnsupportedVersion reports a policy document whose `version` is not + // SchemaVersion. The schema pins `version` to a const so an old daemon + // fails loudly on a new file instead of misreading it; Evaluate enforces + // the same thing on a Policy value that never went through FromDocument. + ErrUnsupportedVersion = errors.New("policy: unsupported policy version") + + // ErrBadPattern reports a glob that cannot be compiled. It is returned, + // never swallowed: a malformed pattern that silently matched nothing + // would be a rule that never fires and never errors -- the failure mode + // the schema's strictness exists to convert into a load-time error. + ErrBadPattern = errors.New("policy: malformed glob pattern") + + // ErrPatternTooComplex reports a glob that exceeds MaxGlobPatternBytes or + // MaxGlobPatternSegments. It is a REFUSAL, not a truncation or a silent + // non-match, and every error carrying it also satisfies errors.Is(err, + // ErrBadPattern) so that callers written before the cap existed still + // branch correctly. + // + // WHY A POLICY FILE HAS A COMPLEXITY CAP AT ALL. `.anvil/policy.yml` is + // read FROM THE REPOSITORY UNDER SCAN, and Anvil scans untrusted + // repositories by design, so the pattern is attacker-controlled input + // reaching a matcher. The matcher below is bounded (see MatchGlob's + // COST section), and this cap bounds its one remaining free variable -- + // the size of the pattern itself. A refused policy is a diagnosable + // outcome; a scanner that spins is research/09 Risk #4's failure mode, + // where a reviewer reads no signal as no problem. + ErrPatternTooComplex = errors.New("policy: glob pattern exceeds the complexity cap") + + // ErrPolicyTooLarge reports a policy, or a policy-plus-context pair, that + // exceeds one of the AGGREGATE bounds: MaxScanRules, MaxListItems, + // MaxChangedPaths or MaxEvaluationMatchOps. Like ErrPatternTooComplex it is + // a REFUSAL, never a truncation and never a partial evaluation. + // + // WHY IT EXISTS SEPARATELY FROM ErrPatternTooComplex. That cap bounds ONE + // pattern. It says nothing about how many patterns there are, and the + // denial of service CRITIQUE O.4 found by recursion is reachable again by + // MULTIPLICATION: ten thousand cheap rules cost the same outage as one + // expensive one, and `.anvil/policy.yml` comes from the repository under + // scan either way. A per-item cap with no aggregate cap is a bounded unit + // price on an unbounded quantity. + // + // The refusals that describe the DOCUMENT — too many rules, too many items + // in a list — also satisfy errors.Is(err, ErrInvalidDocument), because a + // document past a maxItems the schema declares is an invalid document. The + // refusals that describe an EVALUATION — too many changed paths, too much + // total matching work — do not, because the document may be perfectly legal + // and the context is what is out of range. + ErrPolicyTooLarge = errors.New("policy: exceeds an aggregate bound") + + // ErrInvalidDocument reports a document that does not conform to + // schemas/policy.schema.json. FromDocument wraps it with the JSON-pointer + // -ish path of the offending node. + ErrInvalidDocument = errors.New("policy: invalid policy document") +) + +// SchemaVersion is the only `version` value that exists. It is the POLICY FILE +// schema version, not an Anvil release version. A future 2 must be added as a +// new value rather than reinterpreting 1. +const SchemaVersion = 1 + +// --------------------------------------------------------------------------- +// The two enums the policy schema OWNS, as Go values +// --------------------------------------------------------------------------- + +// Depth is how much of the tree a scan covers. +// +// schemas/policy.schema.json#/$defs/depth owns this enum ("This enum IS owned +// here, by O.5, because no other area declares it; O.6 and O.8 consume these +// two tokens rather than declaring a third"). The schema is a JSON document +// and cannot be imported, so THIS is its one Go image: consumers use these +// constants and do not declare a third spelling. The engine never branches on +// a particular depth -- these exist so the loader can reject a typo, and so +// callers naming a depth in Go name it once. +type Depth string + +const ( + DepthDelta Depth = "delta" + DepthFull Depth = "full" +) + +// DepthValues returns every legal `depth` token, in schema order. +func DepthValues() []Depth { return []Depth{DepthDelta, DepthFull} } + +// Valid reports whether d is a legal `depth` token. +func (d Depth) Valid() bool { return slices.Contains(DepthValues(), d) } + +// BumpKind is a kind of semantic-version bump a tag may represent. +// +// schemas/policy.schema.json#/$defs/semverBump owns this enum and names its +// computer: internal/policy/semver.go (O.7), whose `ComputeSemverBump(repoPath, +// newTag string) (BumpKind, error)` returns THIS type. O.7 must not declare a +// second one -- that is the defect class section 6 of the implementation plan +// closed ten instances of. +// +// The bump is COMPUTED by Anvil (`git describe --tags --abbrev=0 ^`), +// never read from a GitHub event payload, which carries no previous tag. +type BumpKind string + +const ( + BumpMajor BumpKind = "major" + BumpMinor BumpKind = "minor" + BumpPatch BumpKind = "patch" + BumpPrerelease BumpKind = "prerelease" +) + +// BumpNone is the zero BumpKind and means "this trigger context carries no +// bump" -- the ref is not a tag, or the bump has not been computed yet. It is +// NOT a fifth bump kind and never appears in a policy file: a rule that lists +// matchSemverBump cannot match a context carrying BumpNone. +const BumpNone BumpKind = "" + +// BumpKindValues returns every legal `matchSemverBump` token, in schema order. +// BumpNone is not among them. +func BumpKindValues() []BumpKind { + return []BumpKind{BumpMajor, BumpMinor, BumpPatch, BumpPrerelease} +} + +// Valid reports whether b is a legal `matchSemverBump` token. BumpNone is not. +func (b BumpKind) Valid() bool { return slices.Contains(BumpKindValues(), b) } + +// --------------------------------------------------------------------------- +// The document shape +// --------------------------------------------------------------------------- + +// Policy is a parsed .anvil/policy.yml. Field for field it is +// schemas/policy.schema.json; that file is the definition and this struct is +// its Go projection, kept honest by TestDecoderKeySetsMatchSchema. +type Policy struct { + Version int `json:"version"` + Defaults *Settings `json:"defaults,omitempty"` + ScanRules []ScanRule `json:"scanRules,omitempty"` +} + +// Settings is the overridable settings block, reachable from `defaults` and +// embedded in every scanRule -- one definition per field, exactly as the +// schema arranges it. +// +// UNSET vs SET is the whole game in a field-by-field merge, so every field +// encodes it: a nil slice, an empty Depth/FailOn, and a nil pointer all mean +// "this layer does not speak to this field". The schema forbids empty arrays +// (minItems: 1) precisely so that "absent" and "constrained to nothing" cannot +// be confused. +type Settings struct { + Detectors []record.DetectorKind `json:"detectors,omitempty"` + Depth Depth `json:"depth,omitempty"` + Timeout *time.Duration `json:"timeout,omitempty"` + FailOn string `json:"failOn,omitempty"` + Publish []string `json:"publish,omitempty"` + Dast *DastOverrides `json:"dast,omitempty"` +} + +// DastOverrides is the DAST-half settings block. Area D EXTENDS the schema's +// $defs/dastOverrides in place; when it does, a field is added here too. Both +// fields are pointers/empty-able for the same set-vs-unset reason as Settings. +type DastOverrides struct { + Profile string `json:"profile,omitempty"` + MaxDuration *time.Duration `json:"maxDuration,omitempty"` +} + +// Schedule is the cadence block of a rule that matches a scheduled event. The +// daemon-side systemd clock is authoritative; GitHub's `schedule:` is a mirror +// only. OnCalendar is passed through VERBATIM -- Anvil neither parses nor +// normalises a calendar expression, because that string is the whole cadence +// definition and re-encoding it in Go would make the cadence code. +type Schedule struct { + OnCalendar string `json:"onCalendar,omitempty"` + Persistent *bool `json:"persistent,omitempty"` + RandomizedDelay *time.Duration `json:"randomizedDelay,omitempty"` +} + +// ScanRule is one match/apply rule. +// +// The match* keys are ANDed: every match* key PRESENT must match. A rule with +// no match* keys matches everything, which is the idiomatic way to write a +// broad baseline that later rules narrow. +// +// Inclusion keys (matchEvents, matchRefs, matchPaths, matchSemverBump) and +// exclusion keys (matchRefsIgnore, matchPathsIgnore) behave asymmetrically +// when the trigger context is silent on that dimension, and the asymmetry is +// deliberate -- see Matches. +type ScanRule struct { + Name string `json:"name"` + + MatchEvents []string `json:"matchEvents,omitempty"` + MatchRefs []string `json:"matchRefs,omitempty"` + MatchRefsIgnore []string `json:"matchRefsIgnore,omitempty"` + MatchPaths []string `json:"matchPaths,omitempty"` + MatchPathsIgnore []string `json:"matchPathsIgnore,omitempty"` + MatchSemverBump []BumpKind `json:"matchSemverBump,omitempty"` + + Schedule *Schedule `json:"schedule,omitempty"` + + Settings +} + +// --------------------------------------------------------------------------- +// The trigger context +// --------------------------------------------------------------------------- + +// TriggerContext is what happened, expressed in the platform's own vocabulary. +// Every field is opaque to this engine: Event is compared for equality against +// whatever tokens the file lists, Ref and ChangedPaths are matched against +// whatever globs the file lists, and SemverBump is compared against whatever +// bump kinds the file lists. +// +// Ref is the FULLY-QUALIFIED ref (refs/heads/main, refs/tags/v2.0.0), because +// that is what the file's globs are written against. +// +// ChangedPaths are repository-relative and SLASH-SEPARATED, on every host +// including Windows. The engine does not translate separators: `\` is a legal +// character in a POSIX filename, so silently rewriting it would corrupt a real +// path to paper over a caller's bug. A caller holding host paths converts with +// filepath.ToSlash before filling this in. +// +// SemverBump is BumpNone unless O.7 computed one for this ref. +type TriggerContext struct { + Event string + Ref string + ChangedPaths []string + SemverBump BumpKind +} + +// --------------------------------------------------------------------------- +// Provenance +// --------------------------------------------------------------------------- + +// RuleRef identifies a rule by ARRAY INDEX first and name second. The index is +// what makes provenance unambiguous even in a hand-built Policy with duplicate +// names; FromDocument rejects duplicate names, but Evaluate does not require +// uniqueness to stay well-defined. +type RuleRef struct { + Index int + Name string +} + +// FieldSource says which layer set one leaf field of the resolved rule. +type FieldSource struct { + // Set is false when no layer set the field at all. + Set bool + // FromDefaults is true when the winning layer was `defaults`. + FromDefaults bool + // Rule is the winning rule when FromDefaults is false. + Rule RuleRef +} + +// Label renders a FieldSource for diagnostics. +func (s FieldSource) Label() string { + switch { + case !s.Set: + return "(unset)" + case s.FromDefaults: + return "defaults" + default: + return fmt.Sprintf("scanRules[%d] %q", s.Rule.Index, s.Rule.Name) + } +} + +// FieldSources is per-leaf-field provenance for a ResolvedRule. It is a STRUCT +// and not a map on purpose: a map would have to be iterated to be reported, +// and an unsorted iteration is the determinism bug this project has already +// shipped once. +type FieldSources struct { + Detectors FieldSource + Depth FieldSource + Timeout FieldSource + FailOn FieldSource + Publish FieldSource + + DastProfile FieldSource + DastMaxDuration FieldSource + + ScheduleOnCalendar FieldSource + SchedulePersistent FieldSource + ScheduleRandomizedDelay FieldSource +} + +// --------------------------------------------------------------------------- +// The resolved rule +// --------------------------------------------------------------------------- + +// ResolvedRule is what a TriggerContext resolves to: `defaults` overlaid by +// every matching rule in array order, field by field. +// +// Matched is empty when NO rule matched. That is not the same as "do not +// scan": the settings still carry whatever `defaults` said. Deciding that an +// unmatched context means no scan is the caller's policy call (the Action and +// the daemon make it), and it is deliberately not made here -- baking it in +// would be this engine overriding the user's data. +type ResolvedRule struct { + Detectors []record.DetectorKind + Depth Depth + Timeout *time.Duration + FailOn string + Publish []string + Dast *DastOverrides + Schedule *Schedule + + // Matched lists the rules that matched, in array order. Last is the + // highest-precedence rule. + Matched []RuleRef + + // Source is per-field provenance: which layer set each leaf field. + Source FieldSources + + // Warnings are non-fatal diagnostics, in a fixed order. The schema + // requires one of them by name: dast overrides on a rule whose resolved + // detectors do not include the dast tier are warned about rather than + // silently ignored. + Warnings []string +} + +// HasDetector reports whether the resolved detector set contains kind. +func (r ResolvedRule) HasDetector(kind record.DetectorKind) bool { + return slices.Contains(r.Detectors, kind) +} + +// MatchedNames returns the matched rule names in array order, for diagnostics. +func (r ResolvedRule) MatchedNames() []string { + out := make([]string, len(r.Matched)) + for i, m := range r.Matched { + out[i] = m.Name + } + return out +} + +// --------------------------------------------------------------------------- +// AGGREGATE BOUNDS — the cost of a policy as a whole, not of one pattern +// --------------------------------------------------------------------------- +// +// MaxGlobPatternBytes and MaxGlobPatternSegments bound ONE pattern. They were +// the answer to CRITIQUE O.4 finding O4-M4, which measured a single pathological +// pattern at 8.51 seconds. They are not an answer to the same denial of service +// reached by MULTIPLICATION, and until these four constants existed there was +// nothing bounding the number of rules, the number of patterns in a rule, or the +// number of changed paths one evaluation would walk. Ten thousand cheap rules +// are the same outage as one expensive one, and the input is attacker-controlled +// in both cases: `.anvil/policy.yml` is read FROM THE REPOSITORY UNDER SCAN, and +// Anvil scans untrusted repositories by design. +// +// # The arithmetic these numbers are chosen against +// +// The matcher is bounded (see MatchGlob's COST section), so the free variable is +// how many times it is CALLED. For one Evaluate: +// +// calls = SUM over rules of +// |matchRefs| + |matchRefsIgnore| (once, vs the ref) +// + (|matchPaths| + |matchPathsIgnore|) x |changedPaths| +// +// The path term is the one that multiplies, and it multiplies THREE ways at +// once, which is why three independent caps do not close this on their own: +// +// MaxScanRules x 2 x MaxListItems x MaxChangedPaths +// = 256 x 2 x 64 x 4096 +// = 134,217,728 calls +// +// Measured on this repository's development machine, one MatchGlob call costs +// ~0.17us for a short pattern, ~1us for a typical one, and ~4us for the worst +// shape the per-pattern caps still admit (a 64-segment `**` pattern against a +// 100-segment path). 134 million of those is between 23 SECONDS and 9 MINUTES, +// per event, on a scanner the attacker triggered by committing a file. The three +// structural caps alone are therefore not a fix: they are three generous limits +// whose PRODUCT is an outage. That is the whole shape of this residual — the +// per-pattern cap bounded the unit price and left the quantity open. +// +// So there are FOUR bounds, and the fourth is the one that actually closes the +// multiplication: MaxEvaluationMatchOps caps the SUM above. It is computed from +// the shape in O(number of rules) arithmetic BEFORE the first path.Match call, so +// an over-budget policy is refused without doing any of the work it asked for. +// +// # Why these particular numbers +// +// MaxScanRules = 256. Renovate's `packageRules`, the convention this schema +// borrows, runs to a few dozen entries in large monorepos; the owner's own +// fixture has four. 256 is roughly sixty times the fixture and beyond any +// hand-maintained trigger policy — a file with 257 rules is not a document +// anybody reads, and refusing it costs a real user nothing. +// +// MaxListItems = 64, per list-valued key: every globList and every tokenList, +// so matchPaths, matchRefs, matchEvents, detectors and publish alike. It is +// deliberately the same 64 as MaxGlobPatternSegments and for the same reason — +// deeper or wider than a human writes. CodeQL's path filters, which matchPaths +// borrows its naming from, run to a dozen or two. At the cap one key already +// carries up to 64 KiB of pattern text. +// +// MaxChangedPaths = 4096. This one is NOT policy data — it is the trigger +// context, i.e. a git diff — so it bounds what a huge commit can cost rather +// than what a crafted file can. Large refactors and generated-code sweeps reach +// the low thousands; past 4096 the delta pass has stopped being a delta, and the +// honest answer is `depth: full`, not a per-path glob walk over the whole tree. +// Refusing tells the operator exactly that. +// +// MaxEvaluationMatchOps = 250,000. At the measured ~4us worst-case call this is +// about one second of matcher work; TestAggregateBoundsAcceptTheCapAndRefuseOneOver +// evaluates a policy sitting exactly on this budget with ordinary patterns and +// logs ~40ms, so the ceiling is a bound and not a cost anyone pays. Real shapes +// fit with room to spare: 50 rules x 10 path patterns x 400 changed paths is +// 200,000. Crafted shapes do not: the 134-million product above is refused after +// 256 additions. A policy that legitimately wants more than this is a policy +// asking for a second of matching per event, and the fix is fewer path globs or +// `depth: full` — not a larger budget. +// +// # Refusal, never truncation +// +// Every bound produces an error naming WHICH bound was exceeded, the observed +// value, and the limit. None of them silently drops a rule, a pattern or a path. +// A refused policy is a diagnosable outcome an operator can act on; a TRUNCATED +// policy is the worst outcome available, because the operator believes rules are +// in force that are not, and Anvil reports a scan that never applied them. +// +// # Enforced in the engine, not only in the schema +// +// schemas/policy.schema.json carries `maxItems` for the two bounds JSON Schema +// can express, and TestPolicySchemaAggregateBoundsMatchTheEngineCaps fails if +// they drift from these constants. That is not sufficient on its own: a Policy +// can reach Evaluate without ever passing through schema validation (a hand-built +// value, a TOML decoding, a caller that skipped the loader), so FromDocument, +// Evaluate and ScanRule.Matches each enforce the bounds themselves. +const ( + MaxScanRules = 256 + MaxListItems = 64 + MaxChangedPaths = 4096 + MaxEvaluationMatchOps = 250_000 +) + +// namedLen pairs a list-valued key with its length. Lengths are all the bounds +// need, and collecting them in a FIXED order keeps a refusal deterministic: +// a document breaking two bounds must name the same one on every run and on +// every host. +type namedLen struct { + key string + n int +} + +// listLengths returns the settings block's list-valued keys, in schema order. +func (s Settings) listLengths() []namedLen { + return []namedLen{ + {"detectors", len(s.Detectors)}, + {"publish", len(s.Publish)}, + } +} + +// listLengths returns every list-valued key on the rule, in schema order. +func (r ScanRule) listLengths() []namedLen { + return append([]namedLen{ + {"matchEvents", len(r.MatchEvents)}, + {"matchRefs", len(r.MatchRefs)}, + {"matchRefsIgnore", len(r.MatchRefsIgnore)}, + {"matchPaths", len(r.MatchPaths)}, + {"matchPathsIgnore", len(r.MatchPathsIgnore)}, + {"matchSemverBump", len(r.MatchSemverBump)}, + }, r.Settings.listLengths()...) +} + +// matchOps is the WORST-CASE number of MatchGlob calls evaluating this rule can +// make against a context carrying changedPaths paths. It is an upper bound, not +// a prediction: Matches short-circuits, so the real count is usually far lower. +// Bounding the worst case is the point — an attacker picks the input that does +// not short-circuit. +func (r ScanRule) matchOps(changedPaths int) int { + perRef := len(r.MatchRefs) + len(r.MatchRefsIgnore) + perPath := len(r.MatchPaths) + len(r.MatchPathsIgnore) + return perRef + perPath*changedPaths +} + +// tooLargeDocument builds a refusal that describes the DOCUMENT, so it matches +// both ErrInvalidDocument and ErrPolicyTooLarge. +func tooLargeDocument(format string, args ...any) error { + return fmt.Errorf("%w: %w: %s", ErrInvalidDocument, ErrPolicyTooLarge, fmt.Sprintf(format, args...)) +} + +// tooLargeEvaluation builds a refusal that describes an EVALUATION — the +// document may be entirely legal and the CONTEXT out of range — so it matches +// ErrPolicyTooLarge alone. +func tooLargeEvaluation(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrPolicyTooLarge, fmt.Sprintf(format, args...)) +} + +// checkScanRuleCount refuses a policy with more rules than MaxScanRules. +// +// It takes the count rather than the policy so FromDocument can refuse a crafted +// file BEFORE decoding a hundred thousand rules into memory. +func checkScanRuleCount(n int, at string) error { + if n <= MaxScanRules { + return nil + } + return tooLargeDocument( + "%s has %d rules, which exceeds the %d-rule cap (policy.MaxScanRules); "+ + "the policy file is read from the repository under scan, so the NUMBER of rules is "+ + "bounded as well as the cost of each one. Nothing has been evaluated and no rule has "+ + "been dropped: this policy is refused, not truncated", + at, n, MaxScanRules) +} + +// checkListBound refuses one over-long list-valued key. +func checkListBound(at string, l namedLen) error { + if l.n <= MaxListItems { + return nil + } + where := l.key + if at != "" { + where = at + "/" + l.key + } + return tooLargeDocument( + "%s has %d items, which exceeds the %d-item cap (policy.MaxListItems); "+ + "the policy file is read from the repository under scan, so the number of patterns in "+ + "a key is bounded as well as the length of each one. This key is refused whole, never "+ + "trimmed to the first %d", + where, l.n, MaxListItems, MaxListItems) +} + +// checkDocumentBounds enforces the two SHAPE bounds — MaxScanRules and +// MaxListItems — on a Policy however it was obtained. It is O(rules). +func checkDocumentBounds(p Policy) error { + if err := checkScanRuleCount(len(p.ScanRules), "/scanRules"); err != nil { + return err + } + if p.Defaults != nil { + for _, l := range p.Defaults.listLengths() { + if err := checkListBound("/defaults", l); err != nil { + return err + } + } + } + for i := range p.ScanRules { + at := fmt.Sprintf("/scanRules/%d", i) + for _, l := range p.ScanRules[i].listLengths() { + if err := checkListBound(at, l); err != nil { + return err + } + } + } + return nil +} + +// checkEvaluationBounds is the whole aggregate check, run BEFORE any matching. +// +// ORDER MATTERS AND IS NOT COSMETIC. The shape bounds run first, so by the time +// the work budget multiplies anything, every factor is already known to be at +// most (MaxScanRules, MaxListItems, MaxChangedPaths). The product cannot +// overflow and the sum cannot run long: the budget check is O(rules) additions +// over a slice whose length was bounded two lines earlier. +func checkEvaluationBounds(p Policy, ctx TriggerContext) error { + if err := checkDocumentBounds(p); err != nil { + return err + } + if n := len(ctx.ChangedPaths); n > MaxChangedPaths { + return tooLargeEvaluation( + "the trigger context carries %d changed paths, which exceeds the %d-path cap "+ + "(policy.MaxChangedPaths); a change set this large is no longer a delta, and the "+ + "answer is depth=%q rather than a per-path glob walk. No path has been ignored: "+ + "the evaluation is refused", + n, MaxChangedPaths, DepthFull) + } + + ops := 0 + for i := range p.ScanRules { + ops += p.ScanRules[i].matchOps(len(ctx.ChangedPaths)) + } + if ops > MaxEvaluationMatchOps { + return tooLargeEvaluation( + "evaluating %d rules against %d changed paths would perform up to %d pattern matches, "+ + "which exceeds the %d-match budget (policy.MaxEvaluationMatchOps); per-pattern caps "+ + "bound the price of one match and this bounds the quantity, which is the same denial "+ + "of service reached by multiplication instead of by recursion. Reduce the path globs "+ + "or use depth=%q; nothing has been matched and nothing has been dropped", + len(p.ScanRules), len(ctx.ChangedPaths), ops, MaxEvaluationMatchOps, DepthFull) + } + return nil +} + +// checkBounds is the per-rule half of the aggregate check, for the callers that +// reach ScanRule.Matches directly instead of going through Evaluate. Matches is +// exported, so it is an entry point in its own right, and an entry point that +// enforced nothing would be the bypass this section exists to close. +func (r ScanRule) checkBounds(changedPaths int) error { + for _, l := range r.listLengths() { + if err := checkListBound("", l); err != nil { + return err + } + } + if changedPaths > MaxChangedPaths { + return tooLargeEvaluation( + "the trigger context carries %d changed paths, which exceeds the %d-path cap "+ + "(policy.MaxChangedPaths)", changedPaths, MaxChangedPaths) + } + if ops := r.matchOps(changedPaths); ops > MaxEvaluationMatchOps { + return tooLargeEvaluation( + "matching this rule against %d changed paths would perform up to %d pattern matches, "+ + "which exceeds the %d-match budget (policy.MaxEvaluationMatchOps)", + changedPaths, ops, MaxEvaluationMatchOps) + } + return nil +} + +// --------------------------------------------------------------------------- +// Evaluate +// --------------------------------------------------------------------------- + +// Evaluate resolves ctx against p. +// +// It applies `defaults`, then every matching rule in array order, merging +// field by field with later-overrides-earlier precedence (see the file header +// for the normative statement). It does not short-circuit on the first match. +// +// Every rule's globs are compiled BEFORE that rule's match keys are evaluated, +// so a malformed pattern is an error whatever the context is. A typo'd glob +// that errored only for the contexts that happened to reach it would be a +// latent, context-dependent failure in the one file that decides whether a +// security scan happens. +// +// The returned ResolvedRule shares no memory with p: slices are copied and +// pointers are freshly allocated, so a caller mutating the result cannot +// change the policy every later evaluation reads. +func Evaluate(p Policy, ctx TriggerContext) (ResolvedRule, error) { + if p.Version != SchemaVersion { + return ResolvedRule{}, fmt.Errorf("%w: have %d, want %d", + ErrUnsupportedVersion, p.Version, SchemaVersion) + } + + // The aggregate bounds, BEFORE any matching. A Policy can reach here + // without passing through FromDocument or through schema validation, so + // this is where the bounds have to hold if they are to hold at all. It is + // O(rules) arithmetic, so an over-budget policy is refused in microseconds + // rather than after the work it asked for. See AGGREGATE BOUNDS above. + if err := checkEvaluationBounds(p, ctx); err != nil { + return ResolvedRule{}, err + } + + var out ResolvedRule + out.applySettings(p.Defaults, FieldSource{Set: true, FromDefaults: true}) + + for i := range p.ScanRules { + rule := p.ScanRules[i] + + ok, err := rule.Matches(ctx) + if err != nil { + return ResolvedRule{}, fmt.Errorf("scanRules[%d] %q: %w", i, rule.Name, err) + } + if !ok { + continue + } + + ref := RuleRef{Index: i, Name: rule.Name} + src := FieldSource{Set: true, Rule: ref} + + out.Matched = append(out.Matched, ref) + out.applySettings(&rule.Settings, src) + out.applySchedule(rule.Schedule, src) + } + + out.Warnings = out.warnings() + return out, nil +} + +// applySettings overlays one layer onto the resolved rule. A field the layer +// leaves unset is not touched, which is what "field by field, later overrides +// earlier" means operationally. +// +// List-valued fields (detectors, publish) are REPLACED wholesale, not unioned. +// A list is one field, and a rule that narrows `detectors` back to the static +// tier must be able to do so; a union would make de-escalation inexpressible. +func (r *ResolvedRule) applySettings(s *Settings, src FieldSource) { + if s == nil { + return + } + + if len(s.Detectors) > 0 { + r.Detectors = slices.Clone(s.Detectors) + r.Source.Detectors = src + } + if s.Depth != "" { + r.Depth = s.Depth + r.Source.Depth = src + } + if s.Timeout != nil { + d := *s.Timeout + r.Timeout = &d + r.Source.Timeout = src + } + if s.FailOn != "" { + r.FailOn = s.FailOn + r.Source.FailOn = src + } + if len(s.Publish) > 0 { + r.Publish = slices.Clone(s.Publish) + r.Source.Publish = src + } + + if s.Dast != nil { + // Nested objects merge per LEAF field, the same way the top level + // does. A rule setting only `dast.profile` therefore keeps an + // earlier layer's `dast.maxDuration` rather than erasing it: "field + // by field" is a statement about leaves, and dast.profile and + // dast.maxDuration are two independent knobs. + if r.Dast == nil { + r.Dast = &DastOverrides{} + } + if s.Dast.Profile != "" { + r.Dast.Profile = s.Dast.Profile + r.Source.DastProfile = src + } + if s.Dast.MaxDuration != nil { + d := *s.Dast.MaxDuration + r.Dast.MaxDuration = &d + r.Source.DastMaxDuration = src + } + } +} + +// applySchedule overlays a rule's cadence block. Schedule lives on the rule, +// not in Settings, so `defaults` cannot set a cadence -- that is the schema's +// arrangement, not a choice made here. Leaves merge exactly like dast's. +func (r *ResolvedRule) applySchedule(s *Schedule, src FieldSource) { + if s == nil { + return + } + if r.Schedule == nil { + r.Schedule = &Schedule{} + } + if s.OnCalendar != "" { + r.Schedule.OnCalendar = s.OnCalendar + r.Source.ScheduleOnCalendar = src + } + if s.Persistent != nil { + b := *s.Persistent + r.Schedule.Persistent = &b + r.Source.SchedulePersistent = src + } + if s.RandomizedDelay != nil { + d := *s.RandomizedDelay + r.Schedule.RandomizedDelay = &d + r.Source.ScheduleRandomizedDelay = src + } +} + +// warnings produces the non-fatal diagnostics, in a fixed order. +// +// The one the schema requires by name: "$defs/dastOverrides ... Only +// meaningful when this rule's resolved `detectors` includes the dast token; +// the engine warns rather than silently ignoring it otherwise." The dast token +// here is area 40's constant, not a literal, and this check changes NOTHING +// about which detectors run -- it emits text. It is a diagnostic, not a +// trigger decision. +func (r ResolvedRule) warnings() []string { + if r.Dast == nil || r.HasDetector(record.DetectorKindDast) { + return nil + } + + var setters []string + for _, s := range []FieldSource{r.Source.DastProfile, r.Source.DastMaxDuration} { + if s.Set && !slices.Contains(setters, s.Label()) { + setters = append(setters, s.Label()) + } + } + + have := make([]string, len(r.Detectors)) + for i, d := range r.Detectors { + have[i] = string(d) + } + + return []string{fmt.Sprintf( + "policy: dast overrides set by %s have no effect: resolved detectors [%s] do not include %q", + strings.Join(setters, ", "), strings.Join(have, " "), record.DetectorKindDast)} +} + +// --------------------------------------------------------------------------- +// Matching +// --------------------------------------------------------------------------- + +// Matches reports whether r applies to ctx. +// +// Every match* key present must match (they are ANDed). A rule with no match* +// keys matches everything. +// +// INCLUSION keys (matchEvents, matchRefs, matchPaths, matchSemverBump) name a +// dimension the context must satisfy. A context that is SILENT on that +// dimension -- no event, no ref, no changed paths, BumpNone -- cannot satisfy +// it, so the rule does not match. This is what makes `matchSemverBump` mean +// "tags only" without any tag-detection logic in Go: a branch push carries no +// bump, so a bump-gated rule cannot fire on it. +// +// EXCLUSION keys (matchRefsIgnore, matchPathsIgnore) name a dimension that can +// only take a match AWAY. A context silent on that dimension cannot trigger an +// exclusion. The asymmetry is intentional: an absent changed-path list must not +// silently exclude a tag push from a rule carrying a docs ignore list. +// +// matchPathsIgnore is applied PER PATH, per the schema: a change set is +// excluded only when EVERY changed path matches an ignore pattern, so a commit +// touching both docs/ and source still triggers the rule. +// +// All of the rule's globs are compiled first, so a malformed pattern errors +// regardless of which key would otherwise have decided the outcome. +func (r ScanRule) Matches(ctx TriggerContext) (bool, error) { + if err := r.checkBounds(len(ctx.ChangedPaths)); err != nil { + return false, err + } + if err := r.validateGlobs(); err != nil { + return false, err + } + + if len(r.MatchEvents) > 0 { + if ctx.Event == "" || !slices.Contains(r.MatchEvents, ctx.Event) { + return false, nil + } + } + + if len(r.MatchRefs) > 0 { + if ctx.Ref == "" { + return false, nil // an inclusion key a silent context cannot satisfy + } + ok, err := anyGlobMatches(r.MatchRefs, ctx.Ref) + if err != nil || !ok { + return false, err + } + } + if len(r.MatchRefsIgnore) > 0 && ctx.Ref != "" { + ok, err := anyGlobMatches(r.MatchRefsIgnore, ctx.Ref) + if err != nil { + return false, err + } + if ok { + return false, nil + } + } + + if len(r.MatchPaths) > 0 { + matched := false + for _, p := range ctx.ChangedPaths { + ok, err := anyGlobMatches(r.MatchPaths, p) + if err != nil { + return false, err + } + if ok { + matched = true + break + } + } + if !matched { + return false, nil + } + } + if len(r.MatchPathsIgnore) > 0 && len(ctx.ChangedPaths) > 0 { + all := true + for _, p := range ctx.ChangedPaths { + ok, err := anyGlobMatches(r.MatchPathsIgnore, p) + if err != nil { + return false, err + } + if !ok { + all = false + break + } + } + if all { + return false, nil + } + } + + if len(r.MatchSemverBump) > 0 { + if ctx.SemverBump == BumpNone || !slices.Contains(r.MatchSemverBump, ctx.SemverBump) { + return false, nil + } + } + + return true, nil +} + +// validateGlobs compiles every glob the rule carries, in a fixed key order so +// the error a broken rule produces is the same on every run and on every host. +func (r ScanRule) validateGlobs() error { + for _, group := range []struct { + key string + patterns []string + }{ + {"matchRefs", r.MatchRefs}, + {"matchRefsIgnore", r.MatchRefsIgnore}, + {"matchPaths", r.MatchPaths}, + {"matchPathsIgnore", r.MatchPathsIgnore}, + } { + for i, pattern := range group.patterns { + if err := validateGlob(pattern); err != nil { + return fmt.Errorf("%s[%d]: %w", group.key, i, err) + } + } + } + return nil +} + +// anyGlobMatches reports whether any pattern matches name. +func anyGlobMatches(patterns []string, name string) (bool, error) { + for _, pattern := range patterns { + ok, err := MatchGlob(pattern, name) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + return false, nil +} + +// --------------------------------------------------------------------------- +// Globs +// --------------------------------------------------------------------------- + +// doubleStar is the one pattern segment with cross-segment meaning. It is not +// a policy VALUE -- it is syntax, the same way `*` is, and it is named once +// here rather than being spelled inline three times. +const doubleStar = "**" + +// MaxGlobPatternBytes and MaxGlobPatternSegments are the complexity cap on ONE +// glob pattern. They are the only thing standing between a crafted +// `.anvil/policy.yml` and unbounded work inside Evaluate, so they are stated as +// arithmetic rather than as taste: +// +// total work for one MatchGlob call +// <= O(len(pattern) x len(name)) (see the COST note) +// <= O(MaxGlobPatternBytes x len(name)) +// +// 1 KiB is roughly forty typical patterns' worth of characters in ONE pattern +// and sixty-four segments is deeper than any ref or repository-relative path a +// human writes; a pattern past either bound is a mistake or an attack, and both +// are better answered with an error naming the bound than with a scan that +// never starts. +// +// The same bound appears in schemas/policy.schema.json as `maxLength` on +// #/$defs/glob, and TestPolicySchemaGlobBoundMatchesTheEngineCap fails if the +// two drift. +const ( + MaxGlobPatternBytes = 1024 + MaxGlobPatternSegments = 64 +) + +// MatchGlob reports whether name matches pattern. +// +// It is exported because a glob predicate re-derived per caller is precisely +// how this repository has been bitten before -- five authors independently +// re-implemented one predicate and all five got it wrong. There is one glob +// implementation for policy matching and this is it. +// +// The dialect, stated so it is a contract rather than an accident: +// +// - `/` is the separator. Pattern and name are split on it and matched +// segment by segment. This is path.Match's dialect (NOT filepath.Match -- +// the separator is `/` on every host, because refs and repository-relative +// paths use `/` on every host). +// - `**` as a COMPLETE segment matches zero or more segments. Zero is +// load-bearing: `**` + `/*.md` is how `**/*.md` matches a top-level +// README.md, and `docs/**` matching `docs` itself follows from the same +// rule. +// - `*` matches any run of characters WITHIN one segment, never across `/`. +// - `?` matches one non-`/` character; `[a-z]` and `[^a-z]` are character +// classes; `\` escapes. These are path.Match's, unchanged -- note that its +// negation character is `^`, NOT the shell's `!`, so `[!ab]` is a class +// containing `!`, `a` and `b`. That is the standard library's dialect and +// it is documented here rather than "fixed", because a second glob dialect +// that differs from Go's in one character is worse than one that differs +// in none. +// - `**` that is not a whole segment (`a**b`) is just two `*` to path.Match, +// i.e. within-segment. That is a degenerate spelling, not a feature. +// +// A malformed pattern is ErrBadPattern, never a silent false. A pattern past +// MaxGlobPatternBytes or MaxGlobPatternSegments is ErrPatternTooComplex (which +// also satisfies errors.Is(err, ErrBadPattern)), never a truncated match. +// +// # COST — this is a security property, not a performance note +// +// The policy file is read from the repository under scan, so `pattern` is +// attacker-controlled on the public-repo path. The segment walk below is a +// bottom-up dynamic program over (pattern suffix, name suffix), so `**` costs +// nothing beyond a table cell: +// +// path.Match calls <= (pattern segments) x (name segments) +// work per call <= O(len(pattern segment) x len(name segment)) +// total <= O(len(pattern) x len(name)) +// +// The last line follows because the sum of the pattern's segment lengths is +// len(pattern) and likewise for the name, so the double sum factorises. With +// len(pattern) capped at MaxGlobPatternBytes the whole call is linear in the +// path being matched. +// +// It replaces a recursion that tried every split point for every `**` with no +// memoisation, which CRITIQUE O.4 (O4-M4) measured at 8.5s for ten `**` +// segments against a thirty-segment path and unbounded past eleven. That was +// not a slow path, it was a denial of service against the scanner reachable by +// committing a file. TestPathologicalGlobPatternsTerminateFast is the +// regression. +func MatchGlob(pattern, name string) (bool, error) { + if err := validateGlob(pattern); err != nil { + return false, err + } + return matchSegments(strings.Split(pattern, "/"), strings.Split(name, "/")) +} + +// matchSegments is the `**`-aware segment walk, as a bottom-up dynamic program +// over suffixes. Non-`**` segments are handed to path.Match so the +// within-segment dialect is the standard library's and not a second, subtly +// different one written here. +// +// dp[i][j] is "pat[i:] matches seg[j:]". Only two rows are ever live, so the +// table is O(len(seg)) memory and each cell is computed exactly once: +// +// i == len(pat) dp = (j == len(seg)) +// pat[i] == "**" dp[i][j] = dp[i+1][j] || dp[i][j+1] (zero or more) +// otherwise dp[i][j] = match(pat[i], seg[j]) && dp[i+1][j+1] +// +// The `**` row is where the old recursion blew up: "zero or more segments" +// there meant re-deriving every suffix once per split point, and k independent +// `**` segments multiplied that k times over. Here the same fact is one OR of +// two already-computed cells. +// +// path.Match itself is the standard library's single-backtrack-point matcher, +// which is O(len(pattern) x len(name)) and not exponential, so nothing under +// this function reintroduces the cost the table removes. +func matchSegments(pat, seg []string) (bool, error) { + np, ns := len(pat), len(seg) + + // prev is dp[i+1][*]; cur is dp[i][*]. dp[np][j] is true only for the + // empty name suffix, which is the "pattern exhausted, name exhausted" + // base case. + prev := make([]bool, ns+1) + cur := make([]bool, ns+1) + prev[ns] = true + + for i := np - 1; i >= 0; i-- { + if pat[i] == doubleStar { + cur[ns] = prev[ns] + for j := ns - 1; j >= 0; j-- { + cur[j] = prev[j] || cur[j+1] + } + } else { + cur[ns] = false // a pattern segment cannot match a name that ended + for j := ns - 1; j >= 0; j-- { + ok, err := path.Match(pat[i], seg[j]) + if err != nil { + return false, fmt.Errorf("%w %q: %v", ErrBadPattern, pat[i], err) + } + cur[j] = ok && prev[j+1] + } + } + prev, cur = cur, prev + } + return prev[0], nil +} + +// validateGlob rejects a pattern path.Match would call ErrBadPattern, and a +// pattern past the complexity cap. +// +// It exists because path.Match only reports a malformed pattern when its scan +// REACHES the malformed part: `path.Match("x[", "y")` is (false, nil), because +// matching failed before the bad class. Relying on that would make a typo'd +// pattern an error for some inputs and a silent non-match for others. This +// walks the whole pattern unconditionally, so the rule that carries it fails +// the same way every time. +// +// The cap is checked HERE rather than in MatchGlob because this is the one +// function both entry points share: MatchGlob calls it, and ScanRule.Matches +// calls it through validateGlobs for every pattern on the rule BEFORE any +// matching starts. A rule carrying one over-cap pattern therefore fails to +// evaluate at all, rather than failing only on whichever changed path happens +// to reach it. +func validateGlob(pattern string) error { + bad := func(reason string) error { + return fmt.Errorf("%w %q: %s", ErrBadPattern, pattern, reason) + } + tooComplex := func(reason string) error { + return fmt.Errorf("%w: %w %q: %s", ErrBadPattern, ErrPatternTooComplex, pattern, reason) + } + + if len(pattern) > MaxGlobPatternBytes { + return tooComplex(fmt.Sprintf("%d bytes exceeds the %d-byte cap; the policy file is read from the repository under scan and its patterns are bounded", + len(pattern), MaxGlobPatternBytes)) + } + if n := strings.Count(pattern, "/") + 1; n > MaxGlobPatternSegments { + return tooComplex(fmt.Sprintf("%d segments exceeds the %d-segment cap; the policy file is read from the repository under scan and its patterns are bounded", + n, MaxGlobPatternSegments)) + } + + for i := 0; i < len(pattern); i++ { + switch pattern[i] { + case '\\': + if i+1 >= len(pattern) { + return bad("trailing backslash") + } + i++ + case '[': + j := i + 1 + // path.Match's negation character is '^' and only '^'. + if j < len(pattern) && pattern[j] == '^' { + j++ + } + if j < len(pattern) && pattern[j] == ']' { // a literal ] first + j++ + } + for ; j < len(pattern) && pattern[j] != ']'; j++ { + if pattern[j] == '\\' { + j++ + } + } + if j >= len(pattern) { + return bad("unterminated character class") + } + i = j + } + } + return nil +} + +// --------------------------------------------------------------------------- +// FromDocument: the schema's shape, in Go, once +// --------------------------------------------------------------------------- +// +// Anvil's module graph carries exactly one dependency and a YAML library is +// not on the table, so decoding a policy file is a two-stage job: some decoder +// turns the bytes into a generic document (map[string]any / []any / string / +// bool / integer), and FromDocument turns THAT into a Policy. Splitting it +// this way means the same function validates a .yml, a .yaml and a .toml, and +// means the strictness the schema promises is enforced in exactly one place. +// +// The accepted key sets below are the schema's `properties` lists. They are +// package-level data, not literals scattered through the decoder, so +// TestDecoderKeySetsMatchSchema can read schemas/policy.schema.json and assert +// they are a faithful projection of it. Adding a key to the schema without +// teaching this decoder fails that test; adding one here that the schema does +// not have fails it too. + +// keyDast is the schema's `dast` KEY name. It is a named constant, and the +// only key name that is, because it collides spelling-with area 40's `dast` +// DETECTOR token -- two unrelated vocabularies that happen to share a word. +// Naming it once keeps the collision visible and lets +// TestFrozenEnumsAreNotForked reject a stray detector literal in this file +// without having to guess which of the two a bare "dast" meant. +const keyDast = "dast" + +var ( + keysPolicy = []string{"version", "defaults", "scanRules"} + keysSettings = []string{"detectors", "depth", "timeout", "failOn", "publish", "dast"} + keysScanRule = []string{ + "name", + "matchEvents", "matchRefs", "matchRefsIgnore", + "matchPaths", "matchPathsIgnore", "matchSemverBump", + "schedule", + "detectors", "depth", "timeout", "failOn", "publish", "dast", + } + keysDast = []string{"profile", "maxDuration"} + keysSchedule = []string{"onCalendar", "persistent", "randomizedDelay"} +) + +// FromDocument converts a generically-decoded policy document into a Policy, +// enforcing the parts of schemas/policy.schema.json that decide whether a rule +// can ever fire: +// +// - `version` is present and is SchemaVersion; +// - no unknown keys anywhere (additionalProperties: false). A `matchEvent:` +// typo would otherwise parse cleanly and match nothing, forever, silently +// -- the schema's own stated reason for being strict; +// - list-valued keys are non-empty and duplicate-free (minItems/uniqueItems) +// -- absent means "unconstrained", empty would mean "matches nothing", +// which is always an authoring mistake; +// - `detectors` tokens are area 40's DetectorKind, validated through +// record.ValidateDetectorKind. This file does not re-enumerate them; +// - `depth` and `matchSemverBump` tokens are the schema's own enums; +// - durations parse as Go durations and are not negative; +// - `name` is present and unique across scanRules (the schema says the +// loader enforces this because JSON Schema cannot). +// +// Traversal is deterministic: keys are visited in the fixed order above, never +// by ranging the decoded map. The one map range is the unknown-key scan, whose +// result is sorted before it is reported, so a document with two typos always +// produces the same message. +// +// Explicit null is treated as absent for optional keys, because `defaults:` +// with nothing under it is a normal thing to write. +func FromDocument(doc any) (Policy, error) { + top, err := asMapping(doc, "") + if err != nil { + return Policy{}, err + } + if err := checkKeys(top, keysPolicy, ""); err != nil { + return Policy{}, err + } + + var p Policy + + raw, ok := top["version"] + if !ok || raw == nil { + return Policy{}, fmt.Errorf("%w: /version is required", ErrInvalidDocument) + } + v, err := asInt(raw, "/version") + if err != nil { + return Policy{}, err + } + if v != SchemaVersion { + return Policy{}, fmt.Errorf("%w: /version: have %d, want %d", + ErrUnsupportedVersion, v, SchemaVersion) + } + p.Version = v + + if raw, ok := top["defaults"]; ok && raw != nil { + m, err := asMapping(raw, "/defaults") + if err != nil { + return Policy{}, err + } + if err := checkKeys(m, keysSettings, "/defaults"); err != nil { + return Policy{}, err + } + s, err := settingsFromMapping(m, "/defaults") + if err != nil { + return Policy{}, err + } + p.Defaults = &s + } + + if raw, ok := top["scanRules"]; ok && raw != nil { + items, err := asSequence(raw, "/scanRules") + if err != nil { + return Policy{}, err + } + // Counted BEFORE the decode loop, so a crafted file is refused without + // first being decoded rule by rule into memory. + if err := checkScanRuleCount(len(items), "/scanRules"); err != nil { + return Policy{}, err + } + seen := map[string]int{} + for i, item := range items { + at := fmt.Sprintf("/scanRules/%d", i) + rule, err := scanRuleFromDocument(item, at) + if err != nil { + return Policy{}, err + } + if prev, dup := seen[rule.Name]; dup { + return Policy{}, fmt.Errorf( + "%w: %s: rule name %q is already used by /scanRules/%d; names must be unique", + ErrInvalidDocument, at, rule.Name, prev) + } + seen[rule.Name] = i + p.ScanRules = append(p.ScanRules, rule) + } + } + + return p, nil +} + +func scanRuleFromDocument(doc any, at string) (ScanRule, error) { + m, err := asMapping(doc, at) + if err != nil { + return ScanRule{}, err + } + if err := checkKeys(m, keysScanRule, at); err != nil { + return ScanRule{}, err + } + + var r ScanRule + + name, ok, err := stringField(m, "name", at) + if err != nil { + return ScanRule{}, err + } + if !ok { + return ScanRule{}, fmt.Errorf("%w: %s/name is required", ErrInvalidDocument, at) + } + r.Name = name + + for _, f := range []struct { + key string + dst *[]string + }{ + {"matchEvents", &r.MatchEvents}, + {"matchRefs", &r.MatchRefs}, + {"matchRefsIgnore", &r.MatchRefsIgnore}, + {"matchPaths", &r.MatchPaths}, + {"matchPathsIgnore", &r.MatchPathsIgnore}, + } { + list, _, err := tokenListField(m, f.key, at) + if err != nil { + return ScanRule{}, err + } + *f.dst = list + } + + if list, ok, err := tokenListField(m, "matchSemverBump", at); err != nil { + return ScanRule{}, err + } else if ok { + for i, tok := range list { + b := BumpKind(tok) + if !b.Valid() { + return ScanRule{}, fmt.Errorf("%w: %s/matchSemverBump/%d: %q is not one of %v", + ErrInvalidDocument, at, i, tok, BumpKindValues()) + } + r.MatchSemverBump = append(r.MatchSemverBump, b) + } + } + + if raw, ok := m["schedule"]; ok && raw != nil { + sm, err := asMapping(raw, at+"/schedule") + if err != nil { + return ScanRule{}, err + } + if err := checkKeys(sm, keysSchedule, at+"/schedule"); err != nil { + return ScanRule{}, err + } + var s Schedule + if v, ok, err := stringField(sm, "onCalendar", at+"/schedule"); err != nil { + return ScanRule{}, err + } else if ok { + s.OnCalendar = v + } + if v, ok, err := boolField(sm, "persistent", at+"/schedule"); err != nil { + return ScanRule{}, err + } else if ok { + s.Persistent = &v + } + if v, ok, err := durationField(sm, "randomizedDelay", at+"/schedule"); err != nil { + return ScanRule{}, err + } else if ok { + s.RandomizedDelay = &v + } + r.Schedule = &s + } + + settings, err := settingsFromMapping(m, at) + if err != nil { + return ScanRule{}, err + } + r.Settings = settings + + return r, nil +} + +// settingsFromMapping reads the settings keys out of a mapping that may also +// carry match keys (a scanRule) or nothing else (defaults). Unknown-key +// checking is the caller's, because the two callers allow different key sets. +func settingsFromMapping(m map[string]any, at string) (Settings, error) { + var s Settings + + if list, ok, err := tokenListField(m, "detectors", at); err != nil { + return Settings{}, err + } else if ok { + for i, tok := range list { + // Area 40 owns this vocabulary. It is validated through + // record's own validator, not re-enumerated here. + if err := record.ValidateDetectorKind(tok); err != nil { + return Settings{}, fmt.Errorf("%w: %s/detectors/%d: %v", + ErrInvalidDocument, at, i, err) + } + s.Detectors = append(s.Detectors, record.DetectorKind(tok)) + } + } + + if v, ok, err := stringField(m, "depth", at); err != nil { + return Settings{}, err + } else if ok { + d := Depth(v) + if !d.Valid() { + return Settings{}, fmt.Errorf("%w: %s/depth: %q is not one of %v", + ErrInvalidDocument, at, v, DepthValues()) + } + s.Depth = d + } + + if v, ok, err := durationField(m, "timeout", at); err != nil { + return Settings{}, err + } else if ok { + s.Timeout = &v + } + + if v, ok, err := stringField(m, "failOn", at); err != nil { + return Settings{}, err + } else if ok { + // Opaque on purpose -- see this file's header. + s.FailOn = v + } + + if list, ok, err := tokenListField(m, "publish", at); err != nil { + return Settings{}, err + } else if ok { + s.Publish = list + } + + if raw, ok := m[keyDast]; ok && raw != nil { + at := at + "/" + keyDast + dm, err := asMapping(raw, at) + if err != nil { + return Settings{}, err + } + if err := checkKeys(dm, keysDast, at); err != nil { + return Settings{}, err + } + var d DastOverrides + if v, ok, err := stringField(dm, "profile", at); err != nil { + return Settings{}, err + } else if ok { + d.Profile = v + } + if v, ok, err := durationField(dm, "maxDuration", at); err != nil { + return Settings{}, err + } else if ok { + d.MaxDuration = &v + } + s.Dast = &d + } + + return s, nil +} + +// --------------------------------------------------------------------------- +// Decoding primitives +// --------------------------------------------------------------------------- + +func asMapping(v any, at string) (map[string]any, error) { + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: %s must be a mapping, got %s", ErrInvalidDocument, atOrRoot(at), typeName(v)) + } + return m, nil +} + +func asSequence(v any, at string) ([]any, error) { + s, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%w: %s must be a sequence, got %s", ErrInvalidDocument, atOrRoot(at), typeName(v)) + } + return s, nil +} + +func asInt(v any, at string) (int, error) { + switch n := v.(type) { + case int: + return n, nil + case int64: + return int(n), nil + case float64: + if n == float64(int64(n)) { + return int(n), nil + } + } + return 0, fmt.Errorf("%w: %s must be an integer, got %s", ErrInvalidDocument, atOrRoot(at), typeName(v)) +} + +// checkKeys enforces additionalProperties:false. Unknown keys are collected +// and SORTED before reporting: ranging a map and reporting the first hit would +// make the error text depend on Go's map iteration order, which is exactly the +// determinism defect class this project has already shipped once. +func checkKeys(m map[string]any, allowed []string, at string) error { + var unknown []string + for k := range m { + if !slices.Contains(allowed, k) { + unknown = append(unknown, k) + } + } + if len(unknown) == 0 { + return nil + } + slices.Sort(unknown) + return fmt.Errorf("%w: %s: unknown key(s) %v; allowed keys are %v", + ErrInvalidDocument, atOrRoot(at), unknown, allowed) +} + +func stringField(m map[string]any, key, at string) (string, bool, error) { + raw, ok := m[key] + if !ok || raw == nil { + return "", false, nil + } + s, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("%w: %s/%s must be a string, got %s", + ErrInvalidDocument, atOrRoot(at), key, typeName(raw)) + } + if s == "" { + return "", false, fmt.Errorf("%w: %s/%s must not be empty", ErrInvalidDocument, atOrRoot(at), key) + } + return s, true, nil +} + +func boolField(m map[string]any, key, at string) (bool, bool, error) { + raw, ok := m[key] + if !ok || raw == nil { + return false, false, nil + } + b, ok := raw.(bool) + if !ok { + return false, false, fmt.Errorf("%w: %s/%s must be a boolean, got %s", + ErrInvalidDocument, atOrRoot(at), key, typeName(raw)) + } + return b, true, nil +} + +// durationField parses a Go duration string. Negative and signed forms are +// rejected: schemas/policy.schema.json#/$defs/duration's pattern has no sign, +// and a negative timeout is a scan that is over before it starts. +func durationField(m map[string]any, key, at string) (time.Duration, bool, error) { + s, ok, err := stringField(m, key, at) + if err != nil || !ok { + return 0, false, err + } + if strings.HasPrefix(s, "-") || strings.HasPrefix(s, "+") { + return 0, false, fmt.Errorf("%w: %s/%s: %q must not be signed", + ErrInvalidDocument, atOrRoot(at), key, s) + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, false, fmt.Errorf("%w: %s/%s: %q is not a duration: %v", + ErrInvalidDocument, atOrRoot(at), key, s, err) + } + return d, true, nil +} + +// tokenListField reads a non-empty, duplicate-free list of non-empty strings. +// It backs both $defs/tokenList and $defs/globList, which have the same shape +// and differ only in what the values mean. +func tokenListField(m map[string]any, key, at string) ([]string, bool, error) { + raw, ok := m[key] + if !ok || raw == nil { + return nil, false, nil + } + items, err := asSequence(raw, atOrRoot(at)+"/"+key) + if err != nil { + return nil, false, err + } + if len(items) == 0 { + return nil, false, fmt.Errorf( + "%w: %s/%s must not be empty; omit the key to leave the dimension unconstrained", + ErrInvalidDocument, atOrRoot(at), key) + } + // MaxListItems, enforced HERE because this is the one function every + // list-valued key is decoded through -- globList and tokenList alike. + // + // It is checked before the loop below for a second reason worth stating: + // that loop's duplicate check is a linear scan per item, so it is QUADRATIC + // in the list length. An unbounded list would therefore be a denial of + // service in the decoder as well as in the matcher, reachable without a + // single glob being compiled. + if err := checkListBound(atOrRoot(at), namedLen{key: key, n: len(items)}); err != nil { + return nil, false, err + } + + out := make([]string, 0, len(items)) + for i, item := range items { + s, ok := item.(string) + if !ok { + return nil, false, fmt.Errorf("%w: %s/%s/%d must be a string, got %s", + ErrInvalidDocument, atOrRoot(at), key, i, typeName(item)) + } + if s == "" { + return nil, false, fmt.Errorf("%w: %s/%s/%d must not be empty", + ErrInvalidDocument, atOrRoot(at), key, i) + } + if slices.Contains(out, s) { + return nil, false, fmt.Errorf("%w: %s/%s/%d: %q is a duplicate", + ErrInvalidDocument, atOrRoot(at), key, i, s) + } + out = append(out, s) + } + return out, true, nil +} + +func atOrRoot(at string) string { + if at == "" { + return "(document root)" + } + return at +} + +func typeName(v any) string { + switch v.(type) { + case nil: + return "null" + case string: + return "a string" + case bool: + return "a boolean" + case int, int64, float64: + return "a number" + case []any: + return "a sequence" + case map[string]any: + return "a mapping" + default: + return fmt.Sprintf("%T", v) + } +} diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go new file mode 100644 index 0000000..ca022fe --- /dev/null +++ b/internal/policy/engine_test.go @@ -0,0 +1,1705 @@ +package policy + +// Tests for step O.6, the policy engine. +// +// Two things are being proved here, and they are not the same thing: +// +// 1. The owner's explicit requirement -- "SAST on every push, SAST+DAST only +// on tagged releases, gated by semver bump" -- resolves correctly, end to +// end, from the LITERAL YAML of research/09's example policy. Not from a +// Go struct that says roughly the same thing: from the file text, decoded +// and evaluated, so the schema, the decoder and the evaluator are checked +// against each other rather than against my own restatement. +// +// 2. Nothing about which rule fires is compiled into Go. The genericity tests +// drive the engine with invented event names, invented refs and invented +// detector-free rules; if any vocabulary were hard-coded, they would fail. +// +// The YAML fixture and the tiny test-only YAML decoder both come from +// schema_test.go (step O.5). They are reused rather than copied: a second copy +// of the owner-requirement fixture could drift from the one the schema is +// tested against, and two fixtures claiming to be the same requirement is the +// defect shape this repository keeps closing. + +import ( + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func mustDur(t *testing.T, s string) time.Duration { + t.Helper() + d, err := time.ParseDuration(s) + if err != nil { + t.Fatalf("bad duration in the test itself: %q: %v", s, err) + } + return d +} + +func durPtr(t *testing.T, s string) *time.Duration { + t.Helper() + d := mustDur(t, s) + return &d +} + +func boolPtr(b bool) *bool { return &b } + +// ownerPolicy decodes research/09's example policy -- the one the owner's +// requirement is written in -- into a Policy. +func ownerPolicy(t *testing.T) Policy { + t.Helper() + doc, err := o5yamlDecode(o5fixtureOwnerRequirement) + if err != nil { + t.Fatalf("decoding the owner-requirement fixture: %v", err) + } + p, err := FromDocument(doc) + if err != nil { + t.Fatalf("FromDocument on the owner-requirement fixture: %v", err) + } + return p +} + +func mustEvaluate(t *testing.T, p Policy, ctx TriggerContext) ResolvedRule { + t.Helper() + got, err := Evaluate(p, ctx) + if err != nil { + t.Fatalf("Evaluate(%+v): %v", ctx, err) + } + return got +} + +func detectors(kinds ...record.DetectorKind) []record.DetectorKind { return kinds } + +func durText(d *time.Duration) string { + if d == nil { + return "(unset)" + } + return d.String() +} + +// --------------------------------------------------------------------------- +// 1. The fixture decodes to the shape the rest of this file assumes +// --------------------------------------------------------------------------- + +// TestOwnerFixtureDecodesToPolicy stops every scenario test below from being +// vacuous. If FromDocument silently dropped `scanRules`, every "no rule +// matched" assertion would still pass while proving nothing. +func TestOwnerFixtureDecodesToPolicy(t *testing.T) { + p := ownerPolicy(t) + + if p.Version != SchemaVersion { + t.Errorf("Version = %d, want %d", p.Version, SchemaVersion) + } + if p.Defaults == nil { + t.Fatal("defaults were dropped") + } + if got, want := p.Defaults.Detectors, detectors(record.DetectorKindSast); !slices.Equal(got, want) { + t.Errorf("defaults.detectors = %v, want %v -- DAST must never be a default", got, want) + } + if got := p.Defaults.Depth; got != DepthDelta { + t.Errorf("defaults.depth = %q, want %q", got, DepthDelta) + } + if got, want := durText(p.Defaults.Timeout), "20m0s"; got != want { + t.Errorf("defaults.timeout = %s, want %s", got, want) + } + if got := p.Defaults.FailOn; got != "high" { + t.Errorf("defaults.failOn = %q, want %q (carried opaquely, never interpreted)", got, "high") + } + + wantOrder := []string{"push-delta", "major-release-full", "minor-release-sast-full", "nightly-regression"} + var gotOrder []string + for _, r := range p.ScanRules { + gotOrder = append(gotOrder, r.Name) + } + if !slices.Equal(gotOrder, wantOrder) { + t.Fatalf("scanRules order = %v, want %v -- array order IS the precedence order", gotOrder, wantOrder) + } + + push := p.ScanRules[0] + if got, want := push.MatchEvents, []string{"push"}; !slices.Equal(got, want) { + t.Errorf("push-delta.matchEvents = %v, want %v", got, want) + } + if got, want := push.MatchPathsIgnore, []string{"docs/**", "**/*.md"}; !slices.Equal(got, want) { + t.Errorf("push-delta.matchPathsIgnore = %v, want %v", got, want) + } + + release := p.ScanRules[1] + if got, want := release.MatchSemverBump, []BumpKind{BumpMajor}; !slices.Equal(got, want) { + t.Errorf("major-release-full.matchSemverBump = %v, want %v", got, want) + } + if release.Dast == nil || release.Dast.Profile != "authenticated" { + t.Errorf("major-release-full.dast = %+v, want profile=authenticated", release.Dast) + } + + nightly := p.ScanRules[3] + if nightly.Schedule == nil || nightly.Schedule.OnCalendar != "*-*-* 03:17:00" { + t.Errorf("nightly-regression.schedule = %+v, want the calendar expression verbatim", nightly.Schedule) + } +} + +// --------------------------------------------------------------------------- +// 2. The two owner-requirement scenarios (the packet's named evidence) +// --------------------------------------------------------------------------- + +func TestOwnerRequirementScenarios(t *testing.T) { + p := ownerPolicy(t) + + cases := []struct { + name string + ctx TriggerContext + + wantMatched []string + wantDetectors []record.DetectorKind + wantDepth Depth + wantTimeout string + wantDast bool // resolved detectors include dast + }{ + { + // THE FIRST OWNER REQUIREMENT: SAST on every push. + name: "push to a branch runs SAST at delta depth", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/heads/main", + ChangedPaths: []string{"internal/policy/engine.go"}, + }, + wantMatched: []string{"push-delta"}, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + { + // THE SECOND OWNER REQUIREMENT: SAST+DAST only on tagged + // releases, and only when the bump Anvil computed is major. + name: "major tag push runs SAST+DAST at full depth", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/tags/v2.0.0", + SemverBump: BumpMajor, + }, + wantMatched: []string{"major-release-full"}, + wantDetectors: detectors(record.DetectorKindSast, record.DetectorKindDast), + wantDepth: DepthFull, + wantTimeout: "1h30m0s", + wantDast: true, + }, + { + // The same tag, arriving as a `release` event -- listed + // alongside push because >3 tags at once drops plain push + // events. Both spellings must resolve identically. + name: "major release event resolves the same as the push", + ctx: TriggerContext{ + Event: "release", + Ref: "refs/tags/v2.0.0", + SemverBump: BumpMajor, + }, + wantMatched: []string{"major-release-full"}, + wantDetectors: detectors(record.DetectorKindSast, record.DetectorKindDast), + wantDepth: DepthFull, + wantTimeout: "1h30m0s", + wantDast: true, + }, + { + // A minor bump is a full SAST pass and NO DAST. The timeout + // comes from defaults because that rule sets none -- the + // field-by-field merge, visible in a real fixture. + name: "minor tag push runs SAST only, timeout inherited from defaults", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/tags/v1.1.0", + SemverBump: BumpMinor, + }, + wantMatched: []string{"minor-release-sast-full"}, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthFull, + wantTimeout: "20m0s", + }, + { + name: "patch tag push runs SAST only", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/tags/v1.1.1", + SemverBump: BumpPatch, + }, + wantMatched: []string{"minor-release-sast-full"}, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthFull, + wantTimeout: "20m0s", + }, + { + // No rule in this policy lists prerelease, so nothing fires + // and only defaults survive. DAST in particular must NOT be + // reached by a bump kind nobody opted into. + name: "prerelease tag matches no rule", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/tags/v2.0.0-rc.1", + SemverBump: BumpPrerelease, + }, + wantMatched: nil, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + { + // The bump-gate as a gate: the same tag ref with no computed + // bump fires no bump-gated rule. This is what keeps DAST off + // when O.7 has not run. + name: "tag ref with no computed bump fires no bump-gated rule", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/tags/v2.0.0", + SemverBump: BumpNone, + }, + wantMatched: nil, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + { + // Docs-only push: matchPathsIgnore excludes the rule, because + // EVERY changed path is ignored. + name: "docs-only push matches no rule", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/heads/main", + ChangedPaths: []string{"docs/design.md", "README.md"}, + }, + wantMatched: nil, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + { + // ... but a change set that touches docs AND source is not + // excluded, because the ignore list is applied per path. + name: "mixed docs and source push still matches", + ctx: TriggerContext{ + Event: "push", + Ref: "refs/heads/main", + ChangedPaths: []string{"docs/design.md", "internal/store/schema.sql"}, + }, + wantMatched: []string{"push-delta"}, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + { + name: "scheduled event resolves the nightly rule", + ctx: TriggerContext{ + Event: "schedule", + }, + wantMatched: []string{"nightly-regression"}, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthFull, + wantTimeout: "20m0s", + }, + { + name: "an event no rule names matches nothing", + ctx: TriggerContext{ + Event: "workflow_dispatch", + Ref: "refs/heads/main", + }, + wantMatched: nil, + wantDetectors: detectors(record.DetectorKindSast), + wantDepth: DepthDelta, + wantTimeout: "20m0s", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mustEvaluate(t, p, tc.ctx) + + if !slices.Equal(got.MatchedNames(), tc.wantMatched) { + t.Errorf("matched %v, want %v", got.MatchedNames(), tc.wantMatched) + } + if !slices.Equal(got.Detectors, tc.wantDetectors) { + t.Errorf("detectors = %v, want %v", got.Detectors, tc.wantDetectors) + } + if got.Depth != tc.wantDepth { + t.Errorf("depth = %q, want %q", got.Depth, tc.wantDepth) + } + if durText(got.Timeout) != tc.wantTimeout { + t.Errorf("timeout = %s, want %s", durText(got.Timeout), tc.wantTimeout) + } + if got.HasDetector(record.DetectorKindDast) != tc.wantDast { + t.Errorf("HasDetector(dast) = %v, want %v", got.HasDetector(record.DetectorKindDast), tc.wantDast) + } + + // failOn and publish come from defaults in every scenario of + // this fixture; assert once here so a merge regression that + // dropped defaults could not hide. + if got.FailOn != "high" { + t.Errorf("failOn = %q, want %q", got.FailOn, "high") + } + if !slices.Equal(got.Publish, []string{"sarif"}) { + t.Errorf("publish = %v, want [sarif]", got.Publish) + } + }) + } +} + +// TestOwnerRequirementDastOverridesResolve checks the half of the second +// requirement the scenario table above only summarises: the DAST profile and +// its duration cap reach the resolved rule, and only on the tagged-release +// path. +func TestOwnerRequirementDastOverridesResolve(t *testing.T) { + p := ownerPolicy(t) + + major := mustEvaluate(t, p, TriggerContext{ + Event: "push", Ref: "refs/tags/v2.0.0", SemverBump: BumpMajor, + }) + if major.Dast == nil { + t.Fatal("major release resolved no dast overrides") + } + if major.Dast.Profile != "authenticated" { + t.Errorf("dast.profile = %q, want %q", major.Dast.Profile, "authenticated") + } + if durText(major.Dast.MaxDuration) != "45m0s" { + t.Errorf("dast.maxDuration = %s, want 45m0s", durText(major.Dast.MaxDuration)) + } + if len(major.Warnings) != 0 { + t.Errorf("warnings = %v, want none when the dast tier is actually enabled", major.Warnings) + } + + push := mustEvaluate(t, p, TriggerContext{ + Event: "push", Ref: "refs/heads/main", ChangedPaths: []string{"main.go"}, + }) + if push.Dast != nil { + t.Errorf("branch push resolved dast overrides %+v, want none", push.Dast) + } +} + +// --------------------------------------------------------------------------- +// 3. Evaluation order: the documented rule, not an accident +// --------------------------------------------------------------------------- + +// orderPolicy is three rules that all match the same context. Rule order is +// the whole point, so the fixture is deliberately built so that a +// first-match-wins engine, a whole-object-replace engine and a correct +// field-by-field engine each produce a DIFFERENT answer. +func orderPolicy(t *testing.T) Policy { + t.Helper() + return Policy{ + Version: SchemaVersion, + Defaults: &Settings{ + Detectors: detectors(record.DetectorKindSast), + Depth: DepthDelta, + Timeout: durPtr(t, "20m"), + FailOn: "low", + Publish: []string{"sarif"}, + }, + ScanRules: []ScanRule{ + { + Name: "broad", + Settings: Settings{Depth: DepthFull, FailOn: "medium", Timeout: durPtr(t, "30m")}, + }, + { + Name: "narrower", + MatchEvents: []string{"alpha"}, + Settings: Settings{Detectors: detectors(record.DetectorKindSast, record.DetectorKindSCA), FailOn: "high"}, + }, + { + Name: "narrowest", + MatchEvents: []string{"alpha"}, + MatchRefs: []string{"refs/heads/**"}, + Settings: Settings{Timeout: durPtr(t, "90m")}, + }, + }, + } +} + +func TestEvaluationAppliesEveryMatchingRuleInArrayOrder(t *testing.T) { + p := orderPolicy(t) + got := mustEvaluate(t, p, TriggerContext{Event: "alpha", Ref: "refs/heads/main"}) + + if want := []string{"broad", "narrower", "narrowest"}; !slices.Equal(got.MatchedNames(), want) { + t.Fatalf("matched %v, want %v -- evaluation must not short-circuit on the first match", + got.MatchedNames(), want) + } + + // Field by field, the winner is the LAST matching rule that set the + // field; a field no rule set falls through to defaults. + checks := []struct { + field, got, want, source, wantSource string + }{ + {"depth", string(got.Depth), string(DepthFull), got.Source.Depth.Label(), `scanRules[0] "broad"`}, + {"failOn", got.FailOn, "high", got.Source.FailOn.Label(), `scanRules[1] "narrower"`}, + {"timeout", durText(got.Timeout), "1h30m0s", got.Source.Timeout.Label(), `scanRules[2] "narrowest"`}, + {"publish", strings.Join(got.Publish, ","), "sarif", got.Source.Publish.Label(), "defaults"}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.field, c.got, c.want) + } + if c.source != c.wantSource { + t.Errorf("%s provenance = %s, want %s", c.field, c.source, c.wantSource) + } + } + + // Lists are replaced wholesale by a later rule, not unioned: a rule must + // be able to narrow the detector set back down. + if want := detectors(record.DetectorKindSast, record.DetectorKindSCA); !slices.Equal(got.Detectors, want) { + t.Errorf("detectors = %v, want %v", got.Detectors, want) + } + if got.Source.Detectors.Label() != `scanRules[1] "narrower"` { + t.Errorf("detectors provenance = %s", got.Source.Detectors.Label()) + } +} + +func TestLaterMatchingRuleNarrowsAList(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + Defaults: &Settings{Detectors: detectors(record.DetectorKindSast, record.DetectorKindDast)}, + ScanRules: []ScanRule{ + {Name: "de-escalate", Settings: Settings{Detectors: detectors(record.DetectorKindSast)}}, + }, + } + got := mustEvaluate(t, p, TriggerContext{Event: "anything"}) + if want := detectors(record.DetectorKindSast); !slices.Equal(got.Detectors, want) { + t.Fatalf("detectors = %v, want %v -- a list is one field and is replaced, not unioned "+ + "(a union would make de-escalation inexpressible)", got.Detectors, want) + } +} + +func TestNestedObjectsMergePerLeafField(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + ScanRules: []ScanRule{ + {Name: "sets-maxduration", Settings: Settings{ + Detectors: detectors(record.DetectorKindDast), + Dast: &DastOverrides{MaxDuration: durPtr(t, "45m")}, + }}, + {Name: "sets-profile", Settings: Settings{ + Dast: &DastOverrides{Profile: "authenticated"}, + }}, + }, + } + got := mustEvaluate(t, p, TriggerContext{Event: "anything"}) + + if got.Dast == nil { + t.Fatal("dast overrides were lost") + } + if got.Dast.Profile != "authenticated" { + t.Errorf("dast.profile = %q, want %q", got.Dast.Profile, "authenticated") + } + if durText(got.Dast.MaxDuration) != "45m0s" { + t.Errorf("dast.maxDuration = %s, want 45m0s -- a later rule setting only `profile` must not "+ + "erase an earlier rule's `maxDuration`", durText(got.Dast.MaxDuration)) + } + if got.Source.DastProfile.Label() != `scanRules[1] "sets-profile"` || + got.Source.DastMaxDuration.Label() != `scanRules[0] "sets-maxduration"` { + t.Errorf("dast provenance = profile:%s maxDuration:%s", + got.Source.DastProfile.Label(), got.Source.DastMaxDuration.Label()) + } +} + +func TestScheduleMergesPerLeafFieldAndDefaultsCannotSetIt(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + ScanRules: []ScanRule{ + {Name: "cadence", Schedule: &Schedule{OnCalendar: "*-*-* 03:17:00", Persistent: boolPtr(true)}}, + {Name: "jitter", Schedule: &Schedule{RandomizedDelay: durPtr(t, "20m")}}, + }, + } + got := mustEvaluate(t, p, TriggerContext{Event: "anything"}) + + if got.Schedule == nil { + t.Fatal("schedule was lost") + } + if got.Schedule.OnCalendar != "*-*-* 03:17:00" { + t.Errorf("onCalendar = %q -- the calendar expression is passed through verbatim", got.Schedule.OnCalendar) + } + if got.Schedule.Persistent == nil || !*got.Schedule.Persistent { + t.Errorf("persistent = %v, want true", got.Schedule.Persistent) + } + if durText(got.Schedule.RandomizedDelay) != "20m0s" { + t.Errorf("randomizedDelay = %s, want 20m0s", durText(got.Schedule.RandomizedDelay)) + } +} + +// TestEvaluateIsDeterministic guards the class of bug that has already shipped +// in this repository once: ranging a Go map without sorting its keys. Nothing +// in a resolution may depend on iteration order. +func TestEvaluateIsDeterministic(t *testing.T) { + p := ownerPolicy(t) + ctx := TriggerContext{Event: "push", Ref: "refs/tags/v2.0.0", SemverBump: BumpMajor} + + first := mustEvaluate(t, p, ctx) + for i := 0; i < 500; i++ { + got := mustEvaluate(t, p, ctx) + if !reflect.DeepEqual(first, got) { + t.Fatalf("run %d differed from run 0:\n first = %+v\n got = %+v", i, first, got) + } + } +} + +// TestUnknownKeyErrorIsDeterministic covers the same class on the decode side: +// a document with two typos must always produce the same message, so the +// unknown-key set is sorted rather than reported in map order. +func TestUnknownKeyErrorIsDeterministic(t *testing.T) { + doc := map[string]any{ + "version": int64(1), + "zeta": "x", + "alpha": "y", + "beta": "z", + } + _, err := FromDocument(doc) + if err == nil { + t.Fatal("unknown keys must be rejected") + } + first := err.Error() + if !strings.Contains(first, "[alpha beta zeta]") { + t.Fatalf("unknown keys must be reported sorted, got: %s", first) + } + for i := 0; i < 200; i++ { + _, err := FromDocument(doc) + if err == nil || err.Error() != first { + t.Fatalf("run %d produced a different message:\n first = %s\n got = %v", i, first, err) + } + } +} + +// TestEvaluateDoesNotAliasThePolicy: a caller mutating what it got back must +// not be able to change what the next evaluation sees. +func TestEvaluateDoesNotAliasThePolicy(t *testing.T) { + p := ownerPolicy(t) + ctx := TriggerContext{Event: "push", Ref: "refs/tags/v2.0.0", SemverBump: BumpMajor} + + got := mustEvaluate(t, p, ctx) + got.Detectors[0] = record.DetectorKind("clobbered") + got.Publish[0] = "clobbered" + *got.Timeout = 0 + got.Dast.Profile = "clobbered" + + again := mustEvaluate(t, p, ctx) + if again.Detectors[0] != record.DetectorKindSast { + t.Errorf("detectors aliased the policy: %v", again.Detectors) + } + if again.Publish[0] != "sarif" { + t.Errorf("publish aliased the policy: %v", again.Publish) + } + if durText(again.Timeout) != "1h30m0s" { + t.Errorf("timeout aliased the policy: %s", durText(again.Timeout)) + } + if again.Dast.Profile != "authenticated" { + t.Errorf("dast overrides aliased the policy: %+v", again.Dast) + } +} + +// --------------------------------------------------------------------------- +// 4. Nothing is compiled in +// --------------------------------------------------------------------------- + +// TestEngineIsGenericOverInventedVocabulary is the owner's hard constraint, +// tested directly. Every token here is nonsense that appears nowhere in +// Anvil's source. If any event name, ref shape or path shape were special-cased +// in Go, these would not resolve. +func TestEngineIsGenericOverInventedVocabulary(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + Defaults: &Settings{Depth: DepthDelta}, + ScanRules: []ScanRule{ + { + Name: "moon", + MatchEvents: []string{"moon_phase", "tide_change"}, + MatchRefs: []string{"orbits/luna/**"}, + MatchPaths: []string{"charts/*.tide"}, + Settings: Settings{Depth: DepthFull, FailOn: "lunar"}, + }, + }, + } + + fires := mustEvaluate(t, p, TriggerContext{ + Event: "tide_change", + Ref: "orbits/luna/waxing/gibbous", + ChangedPaths: []string{"charts/spring.tide"}, + }) + if !slices.Equal(fires.MatchedNames(), []string{"moon"}) { + t.Fatalf("invented vocabulary did not resolve: matched %v", fires.MatchedNames()) + } + if fires.Depth != DepthFull || fires.FailOn != "lunar" { + t.Errorf("resolved %q/%q, want full/lunar -- failOn is carried opaquely, never interpreted", + fires.Depth, fires.FailOn) + } + + misses := mustEvaluate(t, p, TriggerContext{ + Event: "moon_phase", + Ref: "orbits/sol/noon", + ChangedPaths: []string{"charts/spring.tide"}, + }) + if len(misses.Matched) != 0 { + t.Errorf("matched %v, want none", misses.MatchedNames()) + } +} + +func TestRuleWithNoMatchKeysMatchesEverything(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + ScanRules: []ScanRule{ + {Name: "baseline", Settings: Settings{Depth: DepthFull}}, + }, + } + for _, ctx := range []TriggerContext{ + {}, + {Event: "push", Ref: "refs/heads/main", ChangedPaths: []string{"a.go"}}, + {Event: "schedule"}, + {Ref: "refs/tags/v1.0.0", SemverBump: BumpMajor}, + } { + got := mustEvaluate(t, p, ctx) + if len(got.Matched) != 1 { + t.Errorf("ctx %+v matched %v, want the baseline rule", ctx, got.MatchedNames()) + } + } +} + +// --------------------------------------------------------------------------- +// 5. Match semantics, key by key +// --------------------------------------------------------------------------- + +func TestMatchesInclusionAndExclusionAsymmetry(t *testing.T) { + cases := []struct { + name string + rule ScanRule + ctx TriggerContext + want bool + }{ + { + name: "inclusion key unsatisfiable by a silent context: no event", + rule: ScanRule{Name: "r", MatchEvents: []string{"push"}}, + ctx: TriggerContext{}, + want: false, + }, + { + name: "inclusion key unsatisfiable by a silent context: no ref", + rule: ScanRule{Name: "r", MatchRefs: []string{"**"}}, + ctx: TriggerContext{Event: "push"}, + want: false, + }, + { + name: "inclusion key unsatisfiable by a silent context: no changed paths", + rule: ScanRule{Name: "r", MatchPaths: []string{"**"}}, + ctx: TriggerContext{Event: "push"}, + want: false, + }, + { + name: "inclusion key unsatisfiable by a silent context: no bump", + rule: ScanRule{Name: "r", MatchSemverBump: []BumpKind{BumpMajor}}, + ctx: TriggerContext{Event: "push", Ref: "refs/tags/v1.0.0"}, + want: false, + }, + { + name: "exclusion key cannot fire on a silent context: no ref", + rule: ScanRule{Name: "r", MatchRefsIgnore: []string{"**"}}, + ctx: TriggerContext{Event: "push"}, + want: true, + }, + { + name: "exclusion key cannot fire on a silent context: no changed paths", + rule: ScanRule{Name: "r", MatchPathsIgnore: []string{"**"}}, + ctx: TriggerContext{Event: "push", Ref: "refs/tags/v1.0.0"}, + want: true, + }, + { + name: "matchRefsIgnore excludes a ref matchRefs accepted", + rule: ScanRule{Name: "r", MatchRefs: []string{"refs/heads/**"}, MatchRefsIgnore: []string{"refs/heads/wip/**"}}, + ctx: TriggerContext{Ref: "refs/heads/wip/spike"}, + want: false, + }, + { + name: "matchRefsIgnore leaves other refs alone", + rule: ScanRule{Name: "r", MatchRefs: []string{"refs/heads/**"}, MatchRefsIgnore: []string{"refs/heads/wip/**"}}, + ctx: TriggerContext{Ref: "refs/heads/main"}, + want: true, + }, + { + name: "matchPathsIgnore excludes only when EVERY path is ignored", + rule: ScanRule{Name: "r", MatchPathsIgnore: []string{"docs/**", "**/*.md"}}, + ctx: TriggerContext{ChangedPaths: []string{"docs/a.md", "CHANGELOG.md", "docs/b/c.txt"}}, + want: false, + }, + { + name: "matchPathsIgnore keeps the rule when one path is not ignored", + rule: ScanRule{Name: "r", MatchPathsIgnore: []string{"docs/**", "**/*.md"}}, + ctx: TriggerContext{ChangedPaths: []string{"docs/a.md", "internal/x.go"}}, + want: true, + }, + { + name: "match keys are ANDed", + rule: ScanRule{Name: "r", MatchEvents: []string{"push"}, MatchRefs: []string{"refs/tags/**"}}, + ctx: TriggerContext{Event: "push", Ref: "refs/heads/main"}, + want: false, + }, + { + name: "event comparison is exact, not case-folded", + rule: ScanRule{Name: "r", MatchEvents: []string{"push"}}, + ctx: TriggerContext{Event: "PUSH"}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.rule.Matches(tc.ctx) + if err != nil { + t.Fatalf("Matches: %v", err) + } + if got != tc.want { + t.Errorf("Matches = %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// 6. Globs +// --------------------------------------------------------------------------- + +func TestMatchGlob(t *testing.T) { + cases := []struct { + pattern, name string + want bool + }{ + {"**", "", true}, + {"**", "a", true}, + {"**", "a/b/c", true}, + {"refs/heads/**", "refs/heads/main", true}, + {"refs/heads/**", "refs/heads/feature/deep/branch", true}, + {"refs/heads/**", "refs/heads", true}, // ** matches zero segments + {"refs/heads/**", "refs/tags/v1", false}, + {"refs/tags/v*", "refs/tags/v2.0.0", true}, + {"refs/tags/v*", "refs/tags/rc1", false}, + {"refs/tags/v*", "refs/tags/v2/extra", false}, // * does not cross / + {"docs/**", "docs/a/b.md", true}, + {"docs/**", "docs", true}, + {"docs/**", "documents/a.md", false}, + {"**/*.md", "README.md", true}, // ** matching zero segments is load-bearing + {"**/*.md", "docs/a/b.md", true}, + {"**/*.md", "docs/a/b.go", false}, + {"a/**/b", "a/b", true}, + {"a/**/b", "a/x/b", true}, + {"a/**/b", "a/x/y/b", true}, + {"a/**/b", "a/x/y/c", false}, + {"*.go", "main.go", true}, + {"*.go", "internal/main.go", false}, + {"charts/?.tide", "charts/a.tide", true}, + {"charts/[ab].tide", "charts/b.tide", true}, + {"charts/[^ab].tide", "charts/b.tide", false}, + {"charts/[^ab].tide", "charts/c.tide", true}, + // path.Match's negation is '^', not '!'. `[!ab]` is the class + // {'!','a','b'} -- documented on MatchGlob, asserted here so the + // dialect cannot drift silently. + {"charts/[!ab].tide", "charts/b.tide", true}, + {"charts/[!ab].tide", "charts/!.tide", true}, + {"charts/[!ab].tide", "charts/c.tide", false}, + } + + for _, tc := range cases { + t.Run(tc.pattern+" vs "+tc.name, func(t *testing.T) { + got, err := MatchGlob(tc.pattern, tc.name) + if err != nil { + t.Fatalf("MatchGlob: %v", err) + } + if got != tc.want { + t.Errorf("MatchGlob(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) + } + }) + } +} + +// TestMalformedGlobErrorsRegardlessOfTheContext is the reason validateGlob +// exists. path.Match only reports a bad pattern when its scan reaches the bad +// part, so a naive implementation would error for some inputs and silently +// not-match for others -- a rule that fails differently depending on which +// commit arrived. +func TestMalformedGlobErrorsRegardlessOfTheContext(t *testing.T) { + for _, pattern := range []string{"refs/[heads/**", "refs/heads/x\\", "a[b-"} { + if _, err := MatchGlob(pattern, "zzz/no/chance/of/a/match"); !errors.Is(err, ErrBadPattern) { + t.Errorf("MatchGlob(%q, ...) err = %v, want ErrBadPattern", pattern, err) + } + } + + rule := ScanRule{ + Name: "broken", + MatchEvents: []string{"push"}, + MatchPaths: []string{"src/[unterminated"}, + } + // The event key alone would have decided "no match"; the malformed glob + // must still surface. + if _, err := rule.Matches(TriggerContext{Event: "release"}); !errors.Is(err, ErrBadPattern) { + t.Errorf("Matches err = %v, want ErrBadPattern even when another key would have short-circuited", err) + } + + p := Policy{Version: SchemaVersion, ScanRules: []ScanRule{rule}} + _, err := Evaluate(p, TriggerContext{Event: "release"}) + if !errors.Is(err, ErrBadPattern) { + t.Fatalf("Evaluate err = %v, want ErrBadPattern", err) + } + if !strings.Contains(err.Error(), `scanRules[0] "broken"`) || + !strings.Contains(err.Error(), "matchPaths[0]") { + t.Errorf("error must name the rule and the key, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// 6b. The glob matcher is BOUNDED — CRITIQUE O.4 finding O4-M4 +// +// `.anvil/policy.yml` is read from the repository under scan. On the public-repo +// path that makes every pattern in it attacker-controlled input reaching a +// matcher, so "how long can one MatchGlob take" is a security question and not a +// benchmark. The previous `**` handling recursed over every split point with no +// memoisation: the critic measured 8.5s for ten `**` segments against a +// thirty-segment path, 29s for one rule over two hundred changed paths, and no +// termination inside a ten-minute test timeout at twelve. +// +// These three tests are the regression. They assert TERMINATION UNDER A BUDGET, +// not a wall-clock speed target, so they do not become flaky on a loaded CI box: +// the bound they check is four orders of magnitude above the measured cost of +// the fixed matcher and four orders of magnitude below the broken one. +// --------------------------------------------------------------------------- + +// globBudget is the per-case time budget. The bottom-up matcher does the work +// below in single-digit milliseconds; the recursive one did not finish at all. +const globBudget = 5 * time.Second + +// runWithin runs fn and fails if it has not returned within budget. It reports +// the elapsed time either way, so a regression shows up as a number in the log +// rather than only as a hung test binary. +func runWithin(t *testing.T, budget time.Duration, name string, fn func()) { + t.Helper() + done := make(chan time.Duration, 1) + go func() { + start := time.Now() + fn() + done <- time.Since(start) + }() + select { + case elapsed := <-done: + t.Logf("%s: %s", name, elapsed) + if elapsed > budget { + t.Errorf("%s took %s, over the %s budget", name, elapsed, budget) + } + case <-time.After(budget): + // The goroutine is left running; the test binary will exit and take it + // with it. Blocking on a matcher that may never return is precisely the + // failure being tested for. + t.Fatalf("%s did not return within %s: the glob matcher is unbounded again (O4-M4)", name, budget) + } +} + +func TestPathologicalGlobPatternsTerminateFast(t *testing.T) { + // The critic's exact construction: k independent `**` segments against an + // n-segment path, with a final literal that cannot match, so every split + // point is explored before the matcher can answer false. + // + // 20 `**` segments is under MaxGlobPatternSegments and is therefore a + // pattern this engine ACCEPTS and must evaluate; the old matcher's cost + // here is O(n^20). + for _, tc := range []struct{ stars, segments int }{ + {6, 20}, {11, 20}, {11, 30}, {20, 40}, {20, 200}, + } { + pattern := strings.Repeat(doubleStar+"/", tc.stars) + "zzz" + name := strings.TrimSuffix(strings.Repeat("a/", tc.segments), "/") + runWithin(t, globBudget, fmt.Sprintf("MatchGlob(%d **, %d segments)", tc.stars, tc.segments), func() { + ok, err := MatchGlob(pattern, name) + if err != nil { + t.Errorf("MatchGlob: %v", err) + } + if ok { + t.Errorf("MatchGlob(%q, %q) = true; the trailing literal cannot match", pattern, name) + } + }) + } +} + +// The cost the critic actually measured inside Evaluate: one rule, one pattern, +// two hundred changed paths, because Matches loops anyGlobMatches per path and +// Evaluate loops that per rule. 29.3s before; this asserts the whole evaluation +// fits in the budget. +func TestPathologicalGlobInsideEvaluateTerminatesFast(t *testing.T) { + paths := make([]string, 200) + for i := range paths { + paths[i] = strings.TrimSuffix(strings.Repeat("a/", 30), "/") + fmt.Sprintf("/f%d.go", i) + } + p := Policy{ + Version: SchemaVersion, + ScanRules: []ScanRule{{ + Name: "pathological", + MatchPaths: []string{strings.Repeat(doubleStar+"/", 12) + "zzz"}, + Settings: Settings{Depth: DepthFull}, + }}, + } + runWithin(t, globBudget, "Evaluate over 200 changed paths with 12 **", func() { + got, err := Evaluate(p, TriggerContext{Event: "push", ChangedPaths: paths}) + if err != nil { + t.Errorf("Evaluate: %v", err) + } + if len(got.MatchedNames()) != 0 { + t.Errorf("MatchedNames = %v, want none", got.MatchedNames()) + } + }) +} + +// Past the cap the pattern is REFUSED, with an error naming the bound. A refused +// policy is a diagnosable outcome; the failure this replaces was a scan that +// never started and never said why. +func TestOverCapGlobPatternsAreRefusedNotMatched(t *testing.T) { + cases := []struct { + name string + pattern string + }{ + {"too many bytes", strings.Repeat("a", MaxGlobPatternBytes+1)}, + {"too many segments", strings.TrimSuffix(strings.Repeat("*/", MaxGlobPatternSegments+1), "/")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := MatchGlob(tc.pattern, "a/b/c") + if !errors.Is(err, ErrPatternTooComplex) { + t.Fatalf("MatchGlob err = %v, want ErrPatternTooComplex", err) + } + // Callers written before the cap existed branch on ErrBadPattern. + if !errors.Is(err, ErrBadPattern) { + t.Errorf("err = %v, must also satisfy errors.Is(err, ErrBadPattern)", err) + } + + // It is refused at RULE level too, before any path is walked, so a + // rule carrying one over-cap pattern cannot evaluate at all. + rule := ScanRule{Name: "over-cap", MatchPaths: []string{tc.pattern}} + if _, err := rule.Matches(TriggerContext{Event: "push"}); !errors.Is(err, ErrPatternTooComplex) { + t.Errorf("Matches err = %v, want ErrPatternTooComplex", err) + } + p := Policy{Version: SchemaVersion, ScanRules: []ScanRule{rule}} + if _, err := Evaluate(p, TriggerContext{Event: "push"}); !errors.Is(err, ErrPatternTooComplex) { + t.Errorf("Evaluate err = %v, want ErrPatternTooComplex", err) + } + }) + } + + // Negative control: exactly AT the cap is legal. A cap that also rejected + // the boundary would be a different, undocumented cap. + atBytes := strings.Repeat("a", MaxGlobPatternBytes) + if _, err := MatchGlob(atBytes, "a"); err != nil { + t.Errorf("a pattern of exactly %d bytes must be accepted, got %v", MaxGlobPatternBytes, err) + } + atSegments := strings.TrimSuffix(strings.Repeat("*/", MaxGlobPatternSegments), "/") + if _, err := MatchGlob(atSegments, "a"); err != nil { + t.Errorf("a pattern of exactly %d segments must be accepted, got %v", MaxGlobPatternSegments, err) + } +} + +// --------------------------------------------------------------------------- +// 6b. AGGREGATE BOUNDS — the same outage, reached by multiplication +// --------------------------------------------------------------------------- +// +// The tests above bound the price of ONE pattern. These bound the QUANTITY. +// Ten thousand cheap rules cost the same outage as one expensive one, and +// `.anvil/policy.yml` comes from the repository under scan in both cases, so a +// per-pattern cap with no aggregate cap is a bounded unit price on an unbounded +// order. See the AGGREGATE BOUNDS section of engine.go for the arithmetic these +// numbers were chosen against. +// +// Every case below asserts three things, because a bound is only useful if all +// three hold: a policy AT the cap still works, a policy PAST it is refused +// FAST, and the refusal NAMES the bound and the limit. + +// aggPolicy builds a policy of n rules, each carrying pathPatterns changed-path +// globs that cannot match anything. +// +// Nothing may match, on purpose: ScanRule.Matches short-circuits on the first +// pattern that hits, so a policy whose patterns match measures the best case. +// The bounds exist for the worst case, which is the one an attacker picks. +func aggPolicy(n, pathPatterns int) Policy { + p := Policy{Version: SchemaVersion, ScanRules: make([]ScanRule, n)} + for i := range p.ScanRules { + r := ScanRule{Name: fmt.Sprintf("r%d", i)} + for j := 0; j < pathPatterns; j++ { + r.MatchPaths = append(r.MatchPaths, fmt.Sprintf("zz%d/**/nope%d.zzz", i, j)) + } + p.ScanRules[i] = r + } + return p +} + +// aggPaths builds n changed paths, none of which any aggPolicy pattern matches. +func aggPaths(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("src/pkg%d/file%d.go", i%64, i) + } + return out +} + +// TestAggregateBoundsAcceptTheCapAndRefuseOneOver walks each of the four bounds +// to its exact edge from both sides. +// +// The policies here are built as Go values and never pass through FromDocument +// or through schema validation, which is the point: the schema's maxItems +// cannot help a Policy that did not come from the schema, so the engine has to +// hold the bound itself. +func TestAggregateBoundsAcceptTheCapAndRefuseOneOver(t *testing.T) { + cases := []struct { + name string + // atCap and overCap differ by exactly one unit of the bound. + atCap, overCap func() (Policy, TriggerContext) + // constant is the Go constant the refusal must name, and limit and + // observed are the two numbers it must carry. + constant string + limit int + observed int + }{ + { + name: "MaxScanRules", + atCap: func() (Policy, TriggerContext) { return aggPolicy(MaxScanRules, 0), TriggerContext{Event: "push"} }, + overCap: func() (Policy, TriggerContext) { return aggPolicy(MaxScanRules+1, 0), TriggerContext{Event: "push"} }, + constant: "MaxScanRules", + limit: MaxScanRules, + observed: MaxScanRules + 1, + }, + { + name: "MaxListItems", + atCap: func() (Policy, TriggerContext) { + return aggPolicy(1, MaxListItems), TriggerContext{Event: "push", ChangedPaths: aggPaths(4)} + }, + overCap: func() (Policy, TriggerContext) { + return aggPolicy(1, MaxListItems+1), TriggerContext{Event: "push", ChangedPaths: aggPaths(4)} + }, + constant: "MaxListItems", + limit: MaxListItems, + observed: MaxListItems + 1, + }, + { + name: "MaxChangedPaths", + atCap: func() (Policy, TriggerContext) { + return aggPolicy(1, 1), TriggerContext{Event: "push", ChangedPaths: aggPaths(MaxChangedPaths)} + }, + overCap: func() (Policy, TriggerContext) { + return aggPolicy(1, 1), TriggerContext{Event: "push", ChangedPaths: aggPaths(MaxChangedPaths + 1)} + }, + constant: "MaxChangedPaths", + limit: MaxChangedPaths, + observed: MaxChangedPaths + 1, + }, + { + // 250 rules x 1 pattern x 1000 paths is exactly the budget; one + // more rule is exactly 1000 over it. + name: "MaxEvaluationMatchOps", + atCap: func() (Policy, TriggerContext) { + return aggPolicy(250, 1), TriggerContext{Event: "push", ChangedPaths: aggPaths(1000)} + }, + overCap: func() (Policy, TriggerContext) { + return aggPolicy(251, 1), TriggerContext{Event: "push", ChangedPaths: aggPaths(1000)} + }, + constant: "MaxEvaluationMatchOps", + limit: MaxEvaluationMatchOps, + observed: 251 * 1000, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // AT the cap: it works, and it works in full. A bound that also + // rejected its own boundary would be a different, undocumented + // bound -- and the operator would be reading the wrong number. + p, ctx := tc.atCap() + runWithin(t, globBudget, tc.name+" at the cap", func() { + if _, err := Evaluate(p, ctx); err != nil { + t.Errorf("a policy exactly AT %s was refused: %v", tc.constant, err) + } + }) + + // PAST the cap: refused, fast. The check is O(rules) arithmetic + // performed before the first path.Match call, so "fast" here means + // microseconds and the budget is enormous slack. + over, overCtx := tc.overCap() + var err error + runWithin(t, globBudget, tc.name+" one over the cap", func() { + _, err = Evaluate(over, overCtx) + }) + if err == nil { + t.Fatalf("a policy one unit past %s was ACCEPTED; the bound does not exist", tc.constant) + } + if !errors.Is(err, ErrPolicyTooLarge) { + t.Errorf("err = %v, want errors.Is(err, ErrPolicyTooLarge)", err) + } + + // The refusal must be actionable: which bound, what the limit is, + // and what was actually seen. "Too large" without the numbers sends + // an operator to guess at a policy they cannot run. + msg := err.Error() + for _, want := range []string{tc.constant, fmt.Sprint(tc.limit), fmt.Sprint(tc.observed)} { + if !strings.Contains(msg, want) { + t.Errorf("the refusal does not carry %q:\n %v", want, err) + } + } + }) + } +} + +// TestTenThousandCheapRulesAreRefusedFast is the residual's own scenario, in its +// own words: "Ten thousand cheap rules is the same outage as one expensive one." +// +// Unbounded, this policy performs 10,000 x 500 = 5,000,000 pattern matches per +// event — several seconds of a scanner doing nothing an operator asked for, on +// every push, triggered by committing a file. It is refused after 1 comparison. +func TestTenThousandCheapRulesAreRefusedFast(t *testing.T) { + p := aggPolicy(10_000, 1) + ctx := TriggerContext{Event: "push", ChangedPaths: aggPaths(500)} + + var err error + runWithin(t, globBudget, "Evaluate over 10,000 cheap rules", func() { + var got ResolvedRule + got, err = Evaluate(p, ctx) + if err == nil { + t.Errorf("10,000 rules evaluated instead of being refused; %d matched", len(got.Matched)) + } + }) + if !errors.Is(err, ErrPolicyTooLarge) { + t.Fatalf("err = %v, want ErrPolicyTooLarge", err) + } + // It is refused on the RULE COUNT, before the work budget ever multiplies + // anything -- which is what keeps the budget arithmetic itself bounded. + if !strings.Contains(err.Error(), "MaxScanRules") { + t.Errorf("the refusal does not name MaxScanRules:\n %v", err) + } + + // The same file, arriving through the loader, is refused before it is + // decoded rule by rule. + doc := map[string]any{"version": 1, "scanRules": make([]any, 10_000)} + for i := range doc["scanRules"].([]any) { + doc["scanRules"].([]any)[i] = map[string]any{"name": fmt.Sprintf("r%d", i)} + } + runWithin(t, globBudget, "FromDocument over 10,000 cheap rules", func() { + got, ferr := FromDocument(doc) + if ferr == nil { + t.Errorf("FromDocument accepted 10,000 rules") + } + if !errors.Is(ferr, ErrPolicyTooLarge) || !errors.Is(ferr, ErrInvalidDocument) { + t.Errorf("FromDocument err = %v, want both ErrPolicyTooLarge and ErrInvalidDocument", ferr) + } + if len(got.ScanRules) != 0 || got.Version != 0 { + t.Errorf("FromDocument returned a partial policy (%d rules, version %d) alongside its "+ + "error; a refusal must yield nothing", len(got.ScanRules), got.Version) + } + }) +} + +// TestAggregateRefusalIsNeverATruncation is the property the residual singles +// out as the one that must not be got wrong: +// +// "A refused policy is a good outcome; a silently truncated policy is the +// worst outcome, because the operator believes rules are in force that are +// not." +// +// So: an over-cap policy yields NOTHING, and an at-cap policy yields ALL of it. +func TestAggregateRefusalIsNeverATruncation(t *testing.T) { + ctx := TriggerContext{Event: "push"} + + // Over the cap: the zero ResolvedRule and an error. Not the first 256 + // rules, not a best-effort resolution, nothing. + over := aggPolicy(MaxScanRules+1, 0) + over.Defaults = &Settings{Depth: DepthFull, Detectors: detectors(record.DetectorKindSast)} + got, err := Evaluate(over, ctx) + if err == nil { + t.Fatal("an over-cap policy was evaluated") + } + if len(got.Matched) != 0 || len(got.Detectors) != 0 || got.Depth != "" { + t.Errorf("a refused evaluation returned settings (%d matched, detectors %v, depth %q); "+ + "a caller that ignored the error would act on a policy that was never applied", + len(got.Matched), got.Detectors, got.Depth) + } + + // At the cap: every one of the rules is applied. A bound implemented as + // "stop after N" instead of "refuse past N" would pass every assertion + // above and fail this one, which is exactly the silent truncation the + // operator would never see. + atCap := aggPolicy(MaxScanRules, 0) + for i := range atCap.ScanRules { + atCap.ScanRules[i].Settings.FailOn = fmt.Sprintf("level%d", i) + } + resolved, err := Evaluate(atCap, ctx) + if err != nil { + t.Fatalf("a policy exactly at MaxScanRules was refused: %v", err) + } + if len(resolved.Matched) != MaxScanRules { + t.Errorf("%d of %d rules were applied; the rest were dropped silently", + len(resolved.Matched), MaxScanRules) + } + if want := fmt.Sprintf("level%d", MaxScanRules-1); resolved.FailOn != want { + t.Errorf("failOn = %q, want %q from the LAST rule; a truncated evaluation would carry an "+ + "earlier rule's value and look entirely plausible", resolved.FailOn, want) + } +} + +// TestFromDocumentEnforcesTheAggregateBoundsToo covers the loader arm. The +// schema's maxItems and these checks must agree, and +// TestPolicySchemaAggregateBoundsMatchTheEngineCaps is what keeps them agreeing; +// this asserts the loader refuses rather than merely that the numbers match. +func TestFromDocumentEnforcesTheAggregateBoundsToo(t *testing.T) { + rule := func(patterns int) map[string]any { + globs := make([]any, patterns) + for i := range globs { + globs[i] = fmt.Sprintf("src/**/f%d.go", i) + } + return map[string]any{"name": "r", "matchPaths": globs} + } + + cases := []struct { + name string + doc any + want string + }{ + { + name: "one rule past MaxScanRules", + doc: func() any { + rules := make([]any, MaxScanRules+1) + for i := range rules { + rules[i] = map[string]any{"name": fmt.Sprintf("r%d", i)} + } + return map[string]any{"version": 1, "scanRules": rules} + }(), + want: "MaxScanRules", + }, + { + name: "one glob past MaxListItems", + doc: map[string]any{"version": 1, "scanRules": []any{rule(MaxListItems + 1)}}, + want: "MaxListItems", + }, + { + name: "a token list past MaxListItems in defaults", + doc: func() any { + sinks := make([]any, MaxListItems+1) + for i := range sinks { + sinks[i] = fmt.Sprintf("sink%d", i) + } + return map[string]any{"version": 1, "defaults": map[string]any{"publish": sinks}} + }(), + want: "MaxListItems", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, err := FromDocument(tc.doc) + if err == nil { + t.Fatalf("FromDocument accepted an over-cap document (%d rules)", len(p.ScanRules)) + } + if !errors.Is(err, ErrPolicyTooLarge) { + t.Errorf("err = %v, want ErrPolicyTooLarge", err) + } + // A document past a maxItems the schema declares is an invalid + // document, so callers branching on ErrInvalidDocument still work. + if !errors.Is(err, ErrInvalidDocument) { + t.Errorf("err = %v, must also satisfy errors.Is(err, ErrInvalidDocument)", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("the refusal does not name %s:\n %v", tc.want, err) + } + }) + } + + // Negative control: exactly AT each cap, the loader accepts and decodes in + // full. Without this the assertions above would pass against a loader that + // had been broken shut. + rules := make([]any, MaxScanRules) + for i := range rules { + rules[i] = map[string]any{"name": fmt.Sprintf("r%d", i)} + } + rules[0] = rule(MaxListItems) + p, err := FromDocument(map[string]any{"version": 1, "scanRules": rules}) + if err != nil { + t.Fatalf("a document exactly at both caps was refused: %v", err) + } + if len(p.ScanRules) != MaxScanRules { + t.Errorf("decoded %d rules, want %d", len(p.ScanRules), MaxScanRules) + } + if len(p.ScanRules[0].MatchPaths) != MaxListItems { + t.Errorf("decoded %d globs on the first rule, want %d", + len(p.ScanRules[0].MatchPaths), MaxListItems) + } +} + +// TestMatchesEnforcesTheBoundsAtItsOwnEntryPoint: ScanRule.Matches is exported +// and is therefore an entry point in its own right. A caller that reaches it +// directly, without Evaluate, must hit the same bounds — an entry point that +// enforced nothing would be the bypass this section exists to close, and this +// package's sibling has now paid four times for exactly that shape. +func TestMatchesEnforcesTheBoundsAtItsOwnEntryPoint(t *testing.T) { + overList := aggPolicy(1, MaxListItems+1).ScanRules[0] + if _, err := overList.Matches(TriggerContext{Event: "push"}); !errors.Is(err, ErrPolicyTooLarge) { + t.Errorf("Matches on an over-cap pattern list = %v, want ErrPolicyTooLarge", err) + } + + cheap := aggPolicy(1, 1).ScanRules[0] + if _, err := cheap.Matches(TriggerContext{Event: "push", ChangedPaths: aggPaths(MaxChangedPaths + 1)}); !errors.Is(err, ErrPolicyTooLarge) { + t.Errorf("Matches over %d changed paths = %v, want ErrPolicyTooLarge", MaxChangedPaths+1, err) + } + + wide := aggPolicy(1, MaxListItems).ScanRules[0] + if _, err := wide.Matches(TriggerContext{Event: "push", ChangedPaths: aggPaths(MaxChangedPaths)}); !errors.Is(err, ErrPolicyTooLarge) { + t.Errorf("Matches at %d patterns x %d paths (%d ops) = %v, want ErrPolicyTooLarge", + MaxListItems, MaxChangedPaths, MaxListItems*MaxChangedPaths, err) + } + + // Negative control: the ordinary case still matches. + ok, err := cheap.Matches(TriggerContext{Event: "push", ChangedPaths: aggPaths(10)}) + if err != nil { + t.Errorf("an ordinary rule was refused: %v", err) + } + if ok { + t.Error("the control rule matched; its patterns are built not to") + } +} + +// --------------------------------------------------------------------------- +// 7. Warnings and version +// --------------------------------------------------------------------------- + +func TestDastOverridesWithoutTheDastTierWarn(t *testing.T) { + p := Policy{ + Version: SchemaVersion, + Defaults: &Settings{Detectors: detectors(record.DetectorKindSast)}, + ScanRules: []ScanRule{ + {Name: "oops", Settings: Settings{Dast: &DastOverrides{Profile: "authenticated"}}}, + }, + } + got := mustEvaluate(t, p, TriggerContext{Event: "anything"}) + + if len(got.Warnings) != 1 { + t.Fatalf("warnings = %v, want exactly one -- the schema requires a warning rather than "+ + "silent ignoring", got.Warnings) + } + w := got.Warnings[0] + for _, want := range []string{`scanRules[0] "oops"`, string(record.DetectorKindDast), "no effect"} { + if !strings.Contains(w, want) { + t.Errorf("warning %q must mention %q", w, want) + } + } + + // The warning is a diagnostic and changes nothing about the resolution. + if !slices.Equal(got.Detectors, detectors(record.DetectorKindSast)) { + t.Errorf("detectors = %v; a warning must not alter the resolution", got.Detectors) + } + if got.Dast == nil || got.Dast.Profile != "authenticated" { + t.Errorf("dast overrides must still be carried, got %+v", got.Dast) + } +} + +func TestEvaluateRejectsAnUnsupportedVersion(t *testing.T) { + for _, v := range []int{0, 2, -1} { + _, err := Evaluate(Policy{Version: v}, TriggerContext{}) + if !errors.Is(err, ErrUnsupportedVersion) { + t.Errorf("Evaluate(version=%d) err = %v, want ErrUnsupportedVersion", v, err) + } + } +} + +// --------------------------------------------------------------------------- +// 8. The decoder +// --------------------------------------------------------------------------- + +func TestFromDocumentRejects(t *testing.T) { + cases := []struct { + name string + yaml string + wantErr error + want string // substring the message must carry + }{ + { + name: "the matchEvent typo the schema exists to catch", + yaml: "version: 1\nscanRules:\n - name: r\n matchEvent: [push]\n", + wantErr: ErrInvalidDocument, + want: "matchEvent", + }, + { + name: "unknown top-level key", + yaml: "version: 1\nscanRule: []\n", + wantErr: ErrInvalidDocument, + want: "scanRule", + }, + { + name: "unknown dast key", + yaml: "version: 1\ndefaults:\n dast:\n profile: a\n maxDurationn: 5m\n", + wantErr: ErrInvalidDocument, + want: "maxDurationn", + }, + { + name: "missing version", + yaml: "defaults:\n depth: full\n", + wantErr: ErrInvalidDocument, + want: "/version is required", + }, + { + name: "wrong version", + yaml: "version: 2\n", + wantErr: ErrUnsupportedVersion, + want: "want 1", + }, + { + name: "empty list means matches nothing, which is always a mistake", + yaml: "version: 1\nscanRules:\n - name: r\n matchEvents: []\n", + wantErr: ErrInvalidDocument, + want: "must not be empty", + }, + { + name: "duplicate list item", + yaml: "version: 1\nscanRules:\n - name: r\n matchEvents: [push, push]\n", + wantErr: ErrInvalidDocument, + want: "duplicate", + }, + { + name: "duplicate rule name", + yaml: "version: 1\nscanRules:\n - name: r\n - name: r\n", + wantErr: ErrInvalidDocument, + want: "unique", + }, + { + name: "missing rule name", + yaml: "version: 1\nscanRules:\n - depth: full\n", + wantErr: ErrInvalidDocument, + want: "/name is required", + }, + { + name: "depth outside the schema's enum", + yaml: "version: 1\ndefaults:\n depth: deep\n", + wantErr: ErrInvalidDocument, + want: "/depth", + }, + { + name: "detector outside area 40's enum", + yaml: "version: 1\ndefaults:\n detectors: [sast, iast]\n", + wantErr: ErrInvalidDocument, + want: "detectors/1", + }, + { + name: "semver bump outside the schema's enum", + yaml: "version: 1\nscanRules:\n - name: r\n matchSemverBump: [mayor]\n", + wantErr: ErrInvalidDocument, + want: "matchSemverBump/0", + }, + { + name: "duration that is not a duration", + yaml: "version: 1\ndefaults:\n timeout: soon\n", + wantErr: ErrInvalidDocument, + want: "is not a duration", + }, + { + name: "signed duration", + yaml: "version: 1\ndefaults:\n timeout: -20m\n", + wantErr: ErrInvalidDocument, + want: "must not be signed", + }, + { + name: "scanRules is not a sequence", + yaml: "version: 1\nscanRules:\n name: r\n", + wantErr: ErrInvalidDocument, + want: "must be a sequence", + }, + { + name: "persistent is not a boolean", + yaml: "version: 1\nscanRules:\n - name: r\n schedule: { persistent: yesterday }\n", + wantErr: ErrInvalidDocument, + want: "must be a boolean", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doc, err := o5yamlDecode(tc.yaml) + if err != nil { + t.Fatalf("the fixture itself did not decode: %v", err) + } + _, err = FromDocument(doc) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q must mention %q", err, tc.want) + } + }) + } +} + +func TestFromDocumentAcceptsAMinimalPolicy(t *testing.T) { + doc, err := o5yamlDecode("version: 1\n") + if err != nil { + t.Fatalf("decode: %v", err) + } + p, err := FromDocument(doc) + if err != nil { + t.Fatalf("FromDocument: %v", err) + } + got := mustEvaluate(t, p, TriggerContext{Event: "push"}) + + // A policy with no defaults inherits NOTHING implicitly: the engine's own + // fallback is the empty settings object. + if len(got.Detectors) != 0 || got.Depth != "" || got.Timeout != nil || + got.FailOn != "" || len(got.Publish) != 0 || got.Dast != nil { + t.Errorf("empty policy resolved to %+v, want everything unset -- the engine must have no "+ + "built-in defaults of its own", got) + } + if got.Source.Detectors.Label() != "(unset)" { + t.Errorf("provenance = %s, want (unset)", got.Source.Detectors.Label()) + } +} + +// TestDecoderKeySetsMatchSchema keeps the Go decoder a faithful projection of +// schemas/policy.schema.json instead of a second, drifting definition of the +// document shape. Adding a key to the schema without teaching the decoder +// fails here, and so does the reverse. +func TestDecoderKeySetsMatchSchema(t *testing.T) { + raw, err := os.ReadFile("../../" + SchemaPath) + if err != nil { + t.Fatalf("reading the schema: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("parsing the schema: %v", err) + } + + defs, _ := schema["$defs"].(map[string]any) + if defs == nil { + t.Fatal("schema has no $defs") + } + + propsOf := func(node any, where string) []string { + m, ok := node.(map[string]any) + if !ok { + t.Fatalf("%s is not an object", where) + } + props, ok := m["properties"].(map[string]any) + if !ok { + t.Fatalf("%s has no properties", where) + } + out := make([]string, 0, len(props)) + for k := range props { + out = append(out, k) + } + slices.Sort(out) + return out + } + + cases := []struct { + where string + node any + keys []string + }{ + {"(document root)", schema, keysPolicy}, + {"$defs/settings", defs["settings"], keysSettings}, + {"$defs/scanRule", defs["scanRule"], keysScanRule}, + {"$defs/dastOverrides", defs["dastOverrides"], keysDast}, + {"$defs/schedule", defs["schedule"], keysSchedule}, + } + for _, tc := range cases { + want := propsOf(tc.node, tc.where) + got := slices.Clone(tc.keys) + slices.Sort(got) + if !slices.Equal(got, want) { + t.Errorf("%s: decoder accepts %v, schema declares %v", tc.where, got, want) + } + } +} + +// TestFrozenEnumsAreNotForked: the two enums this package owns are the +// schema's, and the detector vocabulary is area 40's. Assert the schema's own +// text still says so, and that this package never re-enumerates detectors. +func TestFrozenEnumsAreNotForked(t *testing.T) { + raw, err := os.ReadFile("../../" + SchemaPath) + if err != nil { + t.Fatalf("reading the schema: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("parsing the schema: %v", err) + } + defs := schema["$defs"].(map[string]any) + + enumOf := func(name string) []string { + node := defs[name].(map[string]any) + vals, ok := node["enum"].([]any) + if !ok { + t.Fatalf("$defs/%s has no enum", name) + } + out := make([]string, len(vals)) + for i, v := range vals { + out[i] = fmt.Sprint(v) + } + return out + } + + var gotDepth []string + for _, d := range DepthValues() { + gotDepth = append(gotDepth, string(d)) + } + if want := enumOf("depth"); !slices.Equal(gotDepth, want) { + t.Errorf("DepthValues() = %v, schema says %v", gotDepth, want) + } + + var gotBump []string + for _, b := range BumpKindValues() { + gotBump = append(gotBump, string(b)) + } + if want := enumOf("semverBump"); !slices.Equal(gotBump, want) { + t.Errorf("BumpKindValues() = %v, schema says %v", gotBump, want) + } + + // The detector vocabulary must be reachable ONLY through area 40. A + // literal detector token anywhere in engine.go's CODE would be a second + // definition of area 40's enum. + // + // This walks the AST rather than grepping, for two reasons: comments and + // doc prose legitimately name the tiers, and `"dast"` is also a schema + // KEY name (the dastOverrides block), which the key-set declarations must + // spell. So the check skips the `keys*` var specs -- whose contents + // TestDecoderKeySetsMatchSchema already pins to the schema -- and looks + // at every other string literal in the file. `keyDast` is skipped by the + // same prefix rule and is the one place the key spelling lives. + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "engine.go", nil, 0) + if err != nil { + t.Fatalf("parsing engine.go: %v", err) + } + + banned := map[string]bool{} + for _, kind := range record.DetectorKindValues() { + banned[string(kind)] = true + } + + ast.Inspect(file, func(n ast.Node) bool { + if spec, ok := n.(*ast.ValueSpec); ok { + for _, name := range spec.Names { + if strings.HasPrefix(name.Name, "key") { + return false // the schema's key names, pinned elsewhere + } + } + } + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + if banned[val] { + t.Errorf("engine.go line %d contains the detector literal %q; detector tokens come "+ + "from internal/record, never from a second list here", + fset.Position(lit.Pos()).Line, val) + } + return true + }) +} diff --git a/internal/policy/locate.go b/internal/policy/locate.go new file mode 100644 index 0000000..4099d1d --- /dev/null +++ b/internal/policy/locate.go @@ -0,0 +1,140 @@ +// Package policy resolves Anvil's trigger policy from the repository it is +// scanning. Trigger policy is DATA: which events fire a scan, which refs and +// paths they apply to, which semver bumps gate a full scan, and on what +// cadence the daemon re-scans are all read from a file in the repository, never +// compiled into Anvil. plan/00-SPINE.md S1 makes that a hard constraint, and +// plan/70-orchestration-ci.md restates the review rule it implies: a literal +// such as "push" or "major" used as a match condition anywhere outside the +// parser is a defect. +// +// This file is step O.5's half of that: FINDING the policy file. Parsing it, +// evaluating its rules (O.6), and computing the semver bump its rules match +// against (O.7) are separate steps in this same package. +// +// The document shape is defined once, in schemas/policy.schema.json, and this +// package points at it by SchemaPath and SchemaID rather than restating it. +// plan/IMPLEMENTATION-PLAN.md section 6 closed ten defects that were all the +// same error -- two areas each defining the shared vocabulary from their own +// side -- so a second schema for this one file, in area D or in the GitHub +// Action (O.8), would be the eleventh. Consumers validate against that file and +// extend it there. +package policy + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// ErrNoPolicyFound reports that none of the SearchOrder candidates exists under +// the given root. It is a normal outcome, not a failure: a repository with no +// policy file is unconfigured, and the caller decides whether that means "do +// nothing" or "apply a built-in default". Test for it with errors.Is; Locate +// wraps it with the root it searched. +var ErrNoPolicyFound = errors.New("policy: no policy file found") + +// Schema locations for consumers that need to validate a policy document. +// These exist so the engine (O.6), the GitHub Action (O.8) and area D's DAST +// overrides all name the SAME schema instead of each shipping their own copy. +const ( + // SchemaPath is the schema's location in the Anvil source tree, + // relative to the repository root. + SchemaPath = "schemas/policy.schema.json" + + // SchemaID is the schema's $id, and the value users put in a + // `# yaml-language-server: $schema=` header. The daemon serves the same + // document locally so schema resolution never requires internet access; + // the .invalid TLD is deliberate -- this identifier is a name, and + // resolving it over the network is not part of the contract. + SchemaID = "https://anvil.invalid/schemas/policy.schema.json" +) + +// searchOrder is the config-file search order, mirroring Renovate: try each +// candidate in turn and stop at the first one that exists +// (research/09-orchestration-and-github-actions.md Recommendation 2). +// +// These are FILE LOCATIONS, not trigger policy. Nothing here is an event name, +// a ref pattern, a semver bump kind or a cadence -- the four kinds of value +// O.5's packet forbids as Go constants. Every matchable value comes from +// inside whichever of these files is found. +// +// Slash-separated on purpose: these are repository-relative paths as users +// write them in documentation, converted to host separators by filepath.Join +// at the point of use, so the same list is correct on Windows. +var searchOrder = [...]string{ + ".anvil/policy.yml", + ".anvil/policy.yaml", + ".anvil/policy.toml", + "anvil.toml", + ".github/anvil.yml", +} + +// SearchOrder returns the candidate paths, repository-relative and +// slash-separated, in precedence order. +// +// It exists so the GitHub Action (O.8), which runs on a runner without the +// daemon and may re-implement the lookup, can read the order from one place +// rather than re-listing it and drifting. It returns a fresh slice on every +// call: a caller that mutates the result must not be able to change where +// every other caller looks for the policy. +func SearchOrder() []string { + out := make([]string, len(searchOrder)) + copy(out, searchOrder[:]) + return out +} + +// Locate returns the path of the policy file to use for the repository rooted +// at root, joined onto root and ready to open. It tries SearchOrder in order +// and stops at the first candidate that exists as a regular file. If none +// exists it returns ErrNoPolicyFound, wrapped with the root that was searched. +// +// An empty root means the current working directory. +// +// Precedence is first-match, so a repository holding several candidates uses +// only the highest-precedence one and the others are inert. Locate does not +// warn about that -- it has one job -- but a caller that wants to surface the +// shadowing can call SearchOrder itself and stat the rest. +// +// A candidate that exists but is NOT a regular file -- a directory named +// .anvil/policy.yml, say -- is skipped, because it cannot be parsed as a +// policy document and treating it as a match would fail later with a confusing +// error. Symlinks are followed, so a symlink to a regular file is a match. +// +// Any stat error other than "does not exist" is returned, and the search STOPS +// there rather than falling through to the next candidate. That matters: if +// .anvil/policy.yml exists but is unreadable, silently continuing would run +// the repository under .github/anvil.yml -- a different policy than the one the +// user wrote, with no diagnostic. Failing loudly is the only safe behaviour for +// a file that decides whether a security scan happens at all. +// +// ENOTDIR is treated as "does not exist" rather than as an error, because it +// means the candidate definitively is not there -- some path component is a +// file. Windows reports that same situation as ErrNotExist, so folding the two +// keeps Locate's behaviour identical on both platforms instead of making a +// repository that scans on Windows fail to scan on Linux. +func Locate(root string) (string, error) { + for _, candidate := range searchOrder { + path := filepath.Join(root, filepath.FromSlash(candidate)) + + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + continue + } + return "", fmt.Errorf("policy: cannot stat candidate %q: %w", path, err) + } + if !info.Mode().IsRegular() { + continue + } + return path, nil + } + + searched := root + if searched == "" { + searched = "." + } + return "", fmt.Errorf("%w under %q (searched %v)", ErrNoPolicyFound, searched, SearchOrder()) +} diff --git a/internal/policy/locate_test.go b/internal/policy/locate_test.go new file mode 100644 index 0000000..3204364 --- /dev/null +++ b/internal/policy/locate_test.go @@ -0,0 +1,221 @@ +package policy + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// o5writeCandidate creates a candidate policy file (and its parent directory) +// under root, using the repository-relative slash form the search order is +// written in. +func o5writeCandidate(t *testing.T, root, candidate string) string { + t.Helper() + + path := filepath.Join(root, filepath.FromSlash(candidate)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll for %q: %v", candidate, err) + } + if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil { + t.Fatalf("WriteFile %q: %v", candidate, err) + } + return path +} + +// TestLocateFindsEachCandidateInIsolation covers all five search paths: each +// one, alone in an otherwise empty tree, must be found. +func TestLocateFindsEachCandidateInIsolation(t *testing.T) { + for _, candidate := range SearchOrder() { + t.Run(candidate, func(t *testing.T) { + root := t.TempDir() + want := o5writeCandidate(t, root, candidate) + + got, err := Locate(root) + if err != nil { + t.Fatalf("Locate: unexpected error: %v", err) + } + if got != want { + t.Errorf("Locate = %q, want %q", got, want) + } + if _, err := os.ReadFile(got); err != nil { + t.Errorf("returned path is not openable: %v", err) + } + }) + } +} + +// TestLocatePrecedenceStopsAtFirstMatch is the search-order test proper. It +// starts with every candidate present and removes them from the front, so each +// step asserts both "this one wins" and "the ones behind it are inert". +func TestLocatePrecedenceStopsAtFirstMatch(t *testing.T) { + order := SearchOrder() + + for removed := range order { + remaining := order[removed:] + + t.Run("winner="+remaining[0], func(t *testing.T) { + root := t.TempDir() + var want string + for i, candidate := range remaining { + path := o5writeCandidate(t, root, candidate) + if i == 0 { + want = path + } + } + + got, err := Locate(root) + if err != nil { + t.Fatalf("Locate: unexpected error: %v", err) + } + if got != want { + t.Errorf("Locate = %q, want %q (candidates present: %v)", got, want, remaining) + } + }) + } +} + +func TestLocateNotFound(t *testing.T) { + root := t.TempDir() + + got, err := Locate(root) + if err == nil { + t.Fatalf("Locate on an empty tree returned %q, want an error", got) + } + if !errors.Is(err, ErrNoPolicyFound) { + t.Fatalf("Locate error = %v, want errors.Is(err, ErrNoPolicyFound)", err) + } + if got != "" { + t.Errorf("Locate returned path %q alongside an error, want empty", got) + } + // The message has to name what was searched: "no policy file found" with + // no list is the kind of diagnostic that sends a user reading source. + for _, candidate := range SearchOrder() { + if !strings.Contains(err.Error(), candidate) { + t.Errorf("error message %q does not mention candidate %q", err, candidate) + } + } +} + +// TestLocateSkipsNonRegularFile: a DIRECTORY named .anvil/policy.yml is not a +// policy file. Matching it would defer the failure to the parser, with a +// confusing error; skipping it lets the next candidate win. +func TestLocateSkipsNonRegularFile(t *testing.T) { + root := t.TempDir() + order := SearchOrder() + + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(order[0])), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + want := o5writeCandidate(t, root, order[1]) + + got, err := Locate(root) + if err != nil { + t.Fatalf("Locate: unexpected error: %v", err) + } + if got != want { + t.Errorf("Locate = %q, want %q (a directory shadowing %q must be skipped)", got, want, order[0]) + } +} + +// TestLocateSkipsNonRegularFileEverywhere runs the same check for every +// position in the order, and for the last one asserts the not-found path: a +// tree whose only candidate is a directory has no policy. +func TestLocateSkipsNonRegularFileEverywhere(t *testing.T) { + for _, candidate := range SearchOrder() { + t.Run(candidate, func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(candidate)), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + _, err := Locate(root) + if !errors.Is(err, ErrNoPolicyFound) { + t.Fatalf("Locate error = %v, want ErrNoPolicyFound", err) + } + }) + } +} + +// TestLocateEmptyRootMeansWorkingDirectory pins the documented behaviour of +// root == "", which is what a caller running inside the checkout passes. +func TestLocateEmptyRootMeansWorkingDirectory(t *testing.T) { + root := t.TempDir() + candidate := SearchOrder()[0] + o5writeCandidate(t, root, candidate) + + t.Chdir(root) + + got, err := Locate("") + if err != nil { + t.Fatalf("Locate(\"\"): unexpected error: %v", err) + } + if want := filepath.FromSlash(candidate); got != want { + t.Errorf("Locate(\"\") = %q, want %q", got, want) + } +} + +func TestLocateEmptyRootNotFoundNamesCurrentDirectory(t *testing.T) { + t.Chdir(t.TempDir()) + + _, err := Locate("") + if !errors.Is(err, ErrNoPolicyFound) { + t.Fatalf("Locate error = %v, want ErrNoPolicyFound", err) + } + if !strings.Contains(err.Error(), `"."`) { + t.Errorf("error message %q should name the current directory as %q", err, ".") + } +} + +// TestSearchOrderIsTheDocumentedOrder pins the list itself. The order is a +// published contract (research/09 Recommendation 2, and the O.8 Action +// re-implements the lookup on the runner), so reordering it must break a test, +// not just a habit. +func TestSearchOrderIsTheDocumentedOrder(t *testing.T) { + want := []string{ + ".anvil/policy.yml", + ".anvil/policy.yaml", + ".anvil/policy.toml", + "anvil.toml", + ".github/anvil.yml", + } + + got := SearchOrder() + if len(got) != len(want) { + t.Fatalf("SearchOrder() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("SearchOrder()[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } +} + +// TestSearchOrderReturnsACopy: the whole point of exporting the order is that +// several consumers share one definition. A caller that mutates the returned +// slice must not be able to change where anyone else looks. +func TestSearchOrderReturnsACopy(t *testing.T) { + first := SearchOrder() + first[0] = "attacker/controlled.yml" + + second := SearchOrder() + if second[0] == first[0] { + t.Fatalf("SearchOrder() exposed shared backing storage: mutation leaked (%q)", second[0]) + } + if second[0] != ".anvil/policy.yml" { + t.Fatalf("SearchOrder()[0] = %q after a caller mutated an earlier result", second[0]) + } +} + +// TestSchemaIdentifiersAreStable guards the constants other areas consume. If +// the schema's $id or path changes, the Action (O.8) and area D must change +// with it, so the change should be deliberate. +func TestSchemaIdentifiersAreStable(t *testing.T) { + if SchemaPath != "schemas/policy.schema.json" { + t.Errorf("SchemaPath = %q, want schemas/policy.schema.json", SchemaPath) + } + if SchemaID != "https://anvil.invalid/schemas/policy.schema.json" { + t.Errorf("SchemaID = %q", SchemaID) + } +} diff --git a/internal/policy/schema_test.go b/internal/policy/schema_test.go new file mode 100644 index 0000000..bf3084a --- /dev/null +++ b/internal/policy/schema_test.go @@ -0,0 +1,1413 @@ +package policy + +// Schema conformance tests for schemas/policy.schema.json (step O.5). +// +// Anvil's module graph carries exactly one dependency (modernc.org/sqlite) and +// adding a YAML library or a JSON Schema library for a test is not on the +// table. So this file carries two small, TEST-ONLY implementations: +// +// - o5yamlDecode: a decoder for the YAML subset policy files are written in +// (block mappings, block sequences, flow sequences and flow mappings, +// comments, quoted and bare scalars). It is deliberately strict and errors +// on anything outside that subset rather than guessing, because a decoder +// that silently drops a key would make every assertion below vacuous. +// +// - o5validateSchema: a validator for the JSON Schema 2020-12 keyword subset +// policy.schema.json actually uses. It REJECTS any keyword it does not +// implement (see TestPolicySchemaUsesOnlySupportedKeywords), so adding +// `allOf` to the schema without teaching the validator fails the build +// instead of quietly validating nothing. +// +// Neither is a general-purpose implementation and neither is exported. The +// production loader (O.6) will parse with whatever the daemon links; these +// exist so the schema's claims are checked here, now, against the fixture the +// owner's requirement is written in. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +const o5schemaFile = "../../" + SchemaPath + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// o5fixtureOwnerRequirement is the policy the owner's explicit requirement +// demands -- SAST on every push, SAST+DAST on tagged releases gated by semver +// bump -- in the shape research/09 Recommendation 2 specifies. It is the +// acceptance fixture: if this does not validate, the schema is wrong, not the +// fixture. +const o5fixtureOwnerRequirement = ` +# yaml-language-server: $schema=https://anvil.invalid/schemas/policy.schema.json +version: 1 + +defaults: + detectors: [sast] # DAST is opt-in, never a default + depth: delta + timeout: 20m + failOn: high + publish: [sarif] + +scanRules: + # SAST-only on every branch push + - name: push-delta + matchEvents: [push] + matchRefs: ["refs/heads/**"] + matchPaths: ["**"] + matchPathsIgnore: ["docs/**", "**/*.md"] + detectors: [sast] + depth: delta + + # SAST + DAST only on release tags, and only for major bumps + - name: major-release-full + matchEvents: [push, release] + matchRefs: ["refs/tags/v*"] + matchSemverBump: [major] + detectors: [sast, dast] + depth: full + timeout: 90m + dast: + profile: authenticated + maxDuration: 45m + + - name: minor-release-sast-full + matchEvents: [push, release] + matchRefs: ["refs/tags/v*"] + matchSemverBump: [minor, patch] + detectors: [sast] + depth: full + + # The real full-scan clock lives on the daemon, not on GitHub + - name: nightly-regression + matchEvents: [schedule] + schedule: { onCalendar: "*-*-* 03:17:00", persistent: true, randomizedDelay: 20m } + detectors: [sast] + depth: full +` + +// o5fixtureBadSemverBump is the same policy with one character changed: a +// matchSemverBump value that is not a bump kind. The packet names this case +// specifically, because matchSemverBump is the one match key whose vocabulary +// Anvil computes rather than reads, so a typo here silently means "this rule +// never fires" -- the exact silent failure the schema exists to catch. +const o5fixtureBadSemverBump = ` +version: 1 + +scanRules: + - name: major-release-full + matchEvents: [push, release] + matchRefs: ["refs/tags/v*"] + matchSemverBump: [mayor] + detectors: [sast, dast] + depth: full +` + +// --------------------------------------------------------------------------- +// The tests the packet requires +// --------------------------------------------------------------------------- + +func TestPolicySchemaAcceptsOwnerRequirementFixture(t *testing.T) { + schema := o5loadSchema(t) + + doc, err := o5yamlDecode(o5fixtureOwnerRequirement) + if err != nil { + t.Fatalf("decoding the fixture failed: %v", err) + } + + if errs := o5validateSchema(schema, doc); len(errs) != 0 { + t.Fatalf("owner-requirement fixture must validate cleanly, got:\n %s", + strings.Join(errs, "\n ")) + } +} + +// TestPolicyFixtureDecodesToTheExpectedShape guards the test above from being +// vacuous. A decoder that dropped `scanRules` entirely would make the fixture +// "validate cleanly" while proving nothing, so assert the structure the +// validator was handed is the structure the fixture describes. +func TestPolicyFixtureDecodesToTheExpectedShape(t *testing.T) { + doc, err := o5yamlDecode(o5fixtureOwnerRequirement) + if err != nil { + t.Fatalf("decode: %v", err) + } + + top, ok := doc.(map[string]any) + if !ok { + t.Fatalf("fixture decoded to %T, want a mapping", doc) + } + if got := top["version"]; !o5deepEqual(got, int64(1)) { + t.Errorf("version = %#v, want 1", got) + } + + defaults, ok := top["defaults"].(map[string]any) + if !ok { + t.Fatalf("defaults decoded to %T, want a mapping", top["defaults"]) + } + if got, want := defaults["detectors"], []any{"sast"}; !o5deepEqual(got, want) { + t.Errorf("defaults.detectors = %#v, want %#v -- DAST must not be a default", got, want) + } + if got := defaults["timeout"]; got != "20m" { + t.Errorf("defaults.timeout = %#v, want \"20m\" (a bare duration must stay a string)", got) + } + + rules, ok := top["scanRules"].([]any) + if !ok { + t.Fatalf("scanRules decoded to %T, want a sequence", top["scanRules"]) + } + if len(rules) != 4 { + t.Fatalf("decoded %d scanRules, want 4", len(rules)) + } + + byName := map[string]map[string]any{} + for i, raw := range rules { + rule, ok := raw.(map[string]any) + if !ok { + t.Fatalf("scanRules[%d] decoded to %T, want a mapping", i, raw) + } + name, _ := rule["name"].(string) + byName[name] = rule + } + + // The two rules the owner's requirement names, checked field by field. + push := byName["push-delta"] + if push == nil { + t.Fatalf("fixture lost the push rule; decoded names: %v", o5keys(byName)) + } + if got, want := push["matchEvents"], []any{"push"}; !o5deepEqual(got, want) { + t.Errorf("push-delta.matchEvents = %#v, want %#v", got, want) + } + if got, want := push["matchPathsIgnore"], []any{"docs/**", "**/*.md"}; !o5deepEqual(got, want) { + t.Errorf("push-delta.matchPathsIgnore = %#v, want %#v", got, want) + } + + release := byName["major-release-full"] + if release == nil { + t.Fatalf("fixture lost the tagged-release rule; decoded names: %v", o5keys(byName)) + } + if got, want := release["matchSemverBump"], []any{"major"}; !o5deepEqual(got, want) { + t.Errorf("major-release-full.matchSemverBump = %#v, want %#v", got, want) + } + if got, want := release["detectors"], []any{"sast", "dast"}; !o5deepEqual(got, want) { + t.Errorf("major-release-full.detectors = %#v, want %#v", got, want) + } + dast, ok := release["dast"].(map[string]any) + if !ok { + t.Fatalf("major-release-full.dast decoded to %T, want a mapping", release["dast"]) + } + if dast["profile"] != "authenticated" || dast["maxDuration"] != "45m" { + t.Errorf("major-release-full.dast = %#v", dast) + } + + nightly := byName["nightly-regression"] + if nightly == nil { + t.Fatalf("fixture lost the schedule rule; decoded names: %v", o5keys(byName)) + } + sched, ok := nightly["schedule"].(map[string]any) + if !ok { + t.Fatalf("nightly-regression.schedule decoded to %T, want a mapping (flow form)", nightly["schedule"]) + } + if sched["onCalendar"] != "*-*-* 03:17:00" { + t.Errorf("schedule.onCalendar = %#v -- the calendar expression must survive verbatim", sched["onCalendar"]) + } + if sched["persistent"] != true { + t.Errorf("schedule.persistent = %#v, want true", sched["persistent"]) + } +} + +// TestPolicySchemaRejects is the negative half. The first case is the one the +// packet names; the rest cover the other silent-failure shapes the schema's +// strictness exists for. +func TestPolicySchemaRejects(t *testing.T) { + schema := o5loadSchema(t) + + cases := []struct { + name string + yaml string + want string + }{ + { + name: "invalid matchSemverBump enum value", + yaml: o5fixtureBadSemverBump, + want: `/scanRules/0/matchSemverBump/0`, + }, + { + name: "misspelled match key is not silently ignored", + yaml: "version: 1\nscanRules:\n - name: r\n matchEvent: [push]\n", + want: `matchEvent`, + }, + { + name: "unknown top-level key", + yaml: "version: 1\nscanRule: []\n", + want: `scanRule`, + }, + { + name: "missing version", + yaml: "scanRules: []\n", + want: `version`, + }, + { + name: "unknown policy-file version", + yaml: "version: 2\n", + want: `/version`, + }, + { + name: "version is not a string", + yaml: `version: "1"` + "\n", + want: `/version`, + }, + { + name: "rule without a name", + yaml: "version: 1\nscanRules:\n - matchEvents: [push]\n", + want: `name`, + }, + { + name: "empty rule name", + yaml: "version: 1\nscanRules:\n - name: \"\"\n", + want: `/scanRules/0/name`, + }, + { + name: "unknown depth", + yaml: "version: 1\ndefaults:\n depth: shallow\n", + want: `/defaults/depth`, + }, + { + name: "empty detector list", + yaml: "version: 1\ndefaults:\n detectors: []\n", + want: `/defaults/detectors`, + }, + { + name: "duplicate match events", + yaml: "version: 1\nscanRules:\n - name: r\n matchEvents: [push, push]\n", + want: `/scanRules/0/matchEvents`, + }, + { + name: "empty match ref list", + yaml: "version: 1\nscanRules:\n - name: r\n matchRefs: []\n", + want: `/scanRules/0/matchRefs`, + }, + { + name: "non-duration timeout", + yaml: "version: 1\ndefaults:\n timeout: 20 minutes\n", + want: `/defaults/timeout`, + }, + { + name: "misspelled dast override key", + yaml: "version: 1\nscanRules:\n - name: r\n dast:\n profil: authenticated\n", + want: `profil`, + }, + { + name: "non-duration randomizedDelay", + yaml: "version: 1\nscanRules:\n - name: r\n schedule: { onCalendar: \"daily\", randomizedDelay: soon }\n", + want: `/scanRules/0/schedule/randomizedDelay`, + }, + { + name: "scanRules is not an array", + yaml: "version: 1\nscanRules:\n name: r\n", + want: `/scanRules`, + }, + { + name: "detector token is not a string", + yaml: "version: 1\ndefaults:\n detectors: [1]\n", + want: `/defaults/detectors/0`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doc, err := o5yamlDecode(tc.yaml) + if err != nil { + t.Fatalf("decode: %v", err) + } + + errs := o5validateSchema(schema, doc) + if len(errs) == 0 { + t.Fatalf("document validated cleanly but must be rejected:\n%s", tc.yaml) + } + joined := strings.Join(errs, "\n ") + if !strings.Contains(joined, tc.want) { + t.Fatalf("rejection did not mention %q; got:\n %s", tc.want, joined) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Schema hygiene +// --------------------------------------------------------------------------- + +func TestPolicySchemaIdentity(t *testing.T) { + schema := o5loadSchema(t) + + if got, want := schema["$id"], SchemaID; got != want { + t.Errorf("$id = %v, want %v (policy.SchemaID is what consumers dereference)", got, want) + } + if got, want := schema["$schema"], "https://json-schema.org/draft/2020-12/schema"; got != want { + t.Errorf("$schema = %v, want %v -- same draft as schemas/anvil-record-v1.schema.json", got, want) + } + if _, ok := schema["title"].(string); !ok { + t.Error("schema has no title") + } +} + +// TestPolicySchemaSearchOrderMatchesLocate keeps the schema's documented search +// order and the code's search order from drifting. Two copies of a list is how +// section 6's ten defects happened; this is the cheap guard against an +// eleventh. +func TestPolicySchemaSearchOrderMatchesLocate(t *testing.T) { + schema := o5loadSchema(t) + + raw, ok := schema["x-anvil-searchOrder"].([]any) + if !ok { + t.Fatalf("x-anvil-searchOrder is %T, want an array", schema["x-anvil-searchOrder"]) + } + got := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + if !ok { + t.Fatalf("x-anvil-searchOrder contains %T, want strings", v) + } + got = append(got, s) + } + + want := SearchOrder() + if len(got) != len(want) { + t.Fatalf("schema search order %v != policy.SearchOrder() %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("search order drifted at %d: schema %q, code %q", i, got[i], want[i]) + } + } +} + +// TestPolicySchemaUsesOnlySupportedKeywords is the anti-vacuous-PASS guard: the +// validator below implements a subset of JSON Schema, and this walks the WHOLE +// schema (including $defs a fixture may never reach) asserting every keyword in +// it is one the validator actually enforces. Without it, adding `allOf` or +// `oneOf` to the schema would leave those constraints unchecked and every test +// above would still pass. +func TestPolicySchemaUsesOnlySupportedKeywords(t *testing.T) { + schema := o5loadSchema(t) + + var errs []string + o5walkSchema(schema, "#", &errs) + if len(errs) != 0 { + sort.Strings(errs) + t.Fatalf("schema uses keywords this test's validator does not enforce:\n %s", + strings.Join(errs, "\n ")) + } +} + +// TestPolicySchemaDoesNotForkFrozenEnums: `detectors` names area 40's +// DetectorKind vocabulary, and `failOn` names area 40's severity vocabulary. +// Neither may be re-enumerated here -- plan/IMPLEMENTATION-PLAN.md section 6 +// ruled that area 40 owns every shared enum and no other area may declare one. +// A copy would validate today and drift tomorrow. +func TestPolicySchemaDoesNotForkFrozenEnums(t *testing.T) { + schema := o5loadSchema(t) + defs, ok := schema["$defs"].(map[string]any) + if !ok { + t.Fatalf("$defs is %T", schema["$defs"]) + } + + detectorList, ok := defs["detectorList"].(map[string]any) + if !ok { + t.Fatalf("$defs/detectorList is %T", defs["detectorList"]) + } + if _, forked := detectorList["enum"]; forked { + t.Error("$defs/detectorList enumerates detector kinds -- that is area 40's DetectorKind, " + + "and copying it here creates the second definition section 6 closed ten of") + } + if _, ok := detectorList["x-anvil-enumSource"].(string); !ok { + t.Error("$defs/detectorList must name where its vocabulary is defined (x-anvil-enumSource)") + } + + // The two enums this schema DOES own must stay owned and stay labelled, + // so a later area consumes them instead of declaring a third copy. + for _, name := range []string{"depth", "semverBump"} { + def, ok := defs[name].(map[string]any) + if !ok { + t.Fatalf("$defs/%s is %T", name, defs[name]) + } + if _, ok := def["enum"].([]any); !ok { + t.Errorf("$defs/%s must enumerate its values -- it is owned here", name) + } + if _, ok := def["x-anvil-enumOwner"].(string); !ok { + t.Errorf("$defs/%s must declare x-anvil-enumOwner", name) + } + } +} + +// TestPolicySchemaGlobBoundMatchesTheEngineCap pins the ONE bound that exists in +// two places: schemas/policy.schema.json#/$defs/glob's `maxLength` and +// internal/policy.MaxGlobPatternBytes. +// +// The bound is not decoration. CRITIQUE O.4 finding O4-M4 established that this +// file is read from the repository under scan, so a pattern in it is untrusted +// input reaching a matcher, and that the matcher was super-polynomial. The +// matcher is now linear in len(pattern) x len(name); the cap is what bounds the +// first factor. A schema that promised a looser bound than the engine enforces +// would send an author a policy that validates and then refuses to run. +func TestPolicySchemaGlobBoundMatchesTheEngineCap(t *testing.T) { + schema := o5loadSchema(t) + defs, ok := schema["$defs"].(map[string]any) + if !ok { + t.Fatalf("$defs is %T", schema["$defs"]) + } + glob, ok := defs["glob"].(map[string]any) + if !ok { + t.Fatalf("$defs/glob is %T", defs["glob"]) + } + + max, ok := o5number(glob["maxLength"]) + if !ok { + t.Fatal("$defs/glob must carry maxLength: an unbounded pattern from the scanned " + + "repository is O4-M4, and the schema must say so as well as the engine") + } + if int(max) != MaxGlobPatternBytes { + t.Errorf("$defs/glob maxLength = %d but policy.MaxGlobPatternBytes = %d; the two bounds have drifted", + int(max), MaxGlobPatternBytes) + } + if _, ok := glob["x-anvil-engineCap"].(string); !ok { + t.Error("$defs/glob must name the Go constant its maxLength mirrors (x-anvil-engineCap)") + } + + // The bound is real on both sides: a pattern one byte over is refused by + // the engine, and one byte at the bound is accepted by both. + if _, err := MatchGlob(strings.Repeat("a", int(max)+1), "a"); !errors.Is(err, ErrPatternTooComplex) { + t.Errorf("a pattern one byte over the schema bound was not refused: %v", err) + } + if _, err := MatchGlob(strings.Repeat("a", int(max)), "a"); err != nil { + t.Errorf("a pattern exactly at the schema bound was refused: %v", err) + } +} + +// TestPolicySchemaAggregateBoundsMatchTheEngineCaps pins the bounds that exist +// in two places, the way TestPolicySchemaGlobBoundMatchesTheEngineCap already +// pins the per-pattern one. +// +// The per-pattern cap bounded the price of one match. It left the QUANTITY +// unbounded, and the re-verification of O.4 counted the consequence: this schema +// contained zero maxItems, so nothing bounded the number of rules or the number +// of patterns in a rule, and the denial of service closed by recursion was open +// again by multiplication. A schema that promised a looser bound than the engine +// enforces would send an author a policy that validates and then refuses to run. +func TestPolicySchemaAggregateBoundsMatchTheEngineCaps(t *testing.T) { + schema := o5loadSchema(t) + defs, ok := schema["$defs"].(map[string]any) + if !ok { + t.Fatalf("$defs is %T", schema["$defs"]) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("properties is %T", schema["properties"]) + } + scanRule, ok := defs["scanRule"].(map[string]any) + if !ok { + t.Fatalf("$defs/scanRule is %T", defs["scanRule"]) + } + ruleProps, ok := scanRule["properties"].(map[string]any) + if !ok { + t.Fatalf("$defs/scanRule/properties is %T", scanRule["properties"]) + } + + // Every array-valued node in the schema, and the Go constant it mirrors. + cases := []struct { + path string + node any + want int + }{ + {"#/properties/scanRules", props["scanRules"], MaxScanRules}, + {"#/$defs/globList", defs["globList"], MaxListItems}, + {"#/$defs/tokenList", defs["tokenList"], MaxListItems}, + {"#/$defs/scanRule/properties/matchSemverBump", ruleProps["matchSemverBump"], MaxListItems}, + } + for _, tc := range cases { + node, ok := tc.node.(map[string]any) + if !ok { + t.Errorf("%s is %T, not a subschema", tc.path, tc.node) + continue + } + max, ok := o5number(node["maxItems"]) + if !ok { + t.Errorf("%s must carry maxItems: an unbounded array from the scanned repository is "+ + "the same denial of service as an unbounded pattern, reached by multiplication "+ + "instead of by recursion", tc.path) + continue + } + if int(max) != tc.want { + t.Errorf("%s maxItems = %d but the engine cap is %d; the two bounds have drifted", + tc.path, int(max), tc.want) + } + if _, ok := node["x-anvil-engineCap"].(string); !ok { + t.Errorf("%s must name the Go constant its maxItems mirrors (x-anvil-engineCap)", tc.path) + } + } + + // EVERY array in the schema must be bounded, not merely the four above. A + // new list-valued key added without maxItems is exactly how this file came + // to have zero of them. + var walk func(sch map[string]any, path string) + walk = func(sch map[string]any, path string) { + if typ, _ := sch["type"].(string); typ == "array" { + if _, ok := o5number(sch["maxItems"]); !ok { + t.Errorf("%s is an unbounded array; every array in this file is read from the "+ + "repository under scan and must carry maxItems", path) + } + } + for _, key := range []string{"properties", "$defs"} { + if m, ok := sch[key].(map[string]any); ok { + for name, sub := range m { + if subm, ok := sub.(map[string]any); ok { + walk(subm, path+"/"+key+"/"+name) + } + } + } + } + if items, ok := sch["items"].(map[string]any); ok { + walk(items, path+"/items") + } + } + walk(schema, "#") + + // The two bounds JSON Schema CANNOT express must still be documented here, + // because a reader of this file would otherwise conclude that maxItems is + // the whole story -- and rules x patterns x paths at the caps above is 134 + // million matches, which is precisely the outage the caps look like they + // prevent. + note, _ := schema["x-anvil-aggregateBounds"].(string) + for _, want := range []string{"MaxScanRules", "MaxListItems", "MaxChangedPaths", "MaxEvaluationMatchOps"} { + if !strings.Contains(note, want) { + t.Errorf("x-anvil-aggregateBounds does not name policy.%s", want) + } + } +} + +// TestPolicySchemaIsStrictEverywhere: every object in the schema must reject +// unknown keys. A policy file is not a document where a typo may be ignored -- +// `matchEvent:` for `matchEvents:` produces a rule that never fires and never +// complains. +func TestPolicySchemaIsStrictEverywhere(t *testing.T) { + schema := o5loadSchema(t) + + var check func(sch map[string]any, path string) + check = func(sch map[string]any, path string) { + if t2, _ := sch["type"].(string); t2 == "object" { + ap, present := sch["additionalProperties"] + if !present || ap != false { + t.Errorf("%s: object schema must set additionalProperties:false (got %#v)", path, ap) + } + } + for _, key := range []string{"properties", "$defs"} { + if m, ok := sch[key].(map[string]any); ok { + for name, sub := range m { + if subm, ok := sub.(map[string]any); ok { + check(subm, path+"/"+key+"/"+name) + } + } + } + } + if items, ok := sch["items"].(map[string]any); ok { + check(items, path+"/items") + } + } + check(schema, "#") +} + +// --------------------------------------------------------------------------- +// Test-only JSON Schema 2020-12 subset validator +// --------------------------------------------------------------------------- + +func o5loadSchema(t *testing.T) map[string]any { + t.Helper() + + raw, err := os.ReadFile(o5schemaFile) + if err != nil { + t.Fatalf("reading %s: %v", o5schemaFile, err) + } + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("%s is not valid JSON: %v", o5schemaFile, err) + } + return schema +} + +// o5dataKeywords are schema keywords whose values are data, not subschemas. +var o5dataKeywords = map[string]bool{ + "$schema": true, "$id": true, "$ref": true, "title": true, + "description": true, "$comment": true, "type": true, "const": true, + "enum": true, "required": true, "minItems": true, "maxItems": true, "uniqueItems": true, + "minLength": true, "maxLength": true, "pattern": true, + "minimum": true, "maximum": true, +} + +func o5walkSchema(sch map[string]any, path string, errs *[]string) { + for key, val := range sch { + if strings.HasPrefix(key, "x-anvil-") || o5dataKeywords[key] { + continue + } + switch key { + case "properties", "$defs": + m, ok := val.(map[string]any) + if !ok { + *errs = append(*errs, fmt.Sprintf("%s/%s: expected an object of subschemas, got %T", path, key, val)) + continue + } + for name, sub := range m { + subm, ok := sub.(map[string]any) + if !ok { + *errs = append(*errs, fmt.Sprintf("%s/%s/%s: expected a subschema, got %T", path, key, name, sub)) + continue + } + o5walkSchema(subm, path+"/"+key+"/"+name, errs) + } + case "items": + subm, ok := val.(map[string]any) + if !ok { + *errs = append(*errs, fmt.Sprintf("%s/items: expected a subschema, got %T", path, val)) + continue + } + o5walkSchema(subm, path+"/items", errs) + case "additionalProperties": + switch v := val.(type) { + case bool: + case map[string]any: + o5walkSchema(v, path+"/additionalProperties", errs) + default: + *errs = append(*errs, fmt.Sprintf("%s/additionalProperties: expected bool or subschema, got %T", path, v)) + } + default: + *errs = append(*errs, fmt.Sprintf("%s: unsupported keyword %q", path, key)) + } + } +} + +// o5validateSchema validates doc against schema and returns every violation, +// sorted so failures read the same way on every run. +func o5validateSchema(schema map[string]any, doc any) []string { + var errs []string + o5validateNode(schema, schema, doc, "", &errs) + sort.Strings(errs) + return errs +} + +func o5validateNode(root, sch map[string]any, val any, path string, errs *[]string) { + if path == "" { + path = "#" + } + + if ref, ok := sch["$ref"].(string); ok { + target, err := o5resolveRef(root, ref) + if err != nil { + *errs = append(*errs, fmt.Sprintf("%s: %v", path, err)) + } else { + o5validateNode(root, target, val, path, errs) + } + } + + if want, ok := sch["type"].(string); ok && !o5typeMatches(want, val) { + *errs = append(*errs, fmt.Sprintf("%s: expected type %q, got %s", path, want, o5typeOf(val))) + return // downstream keyword checks would only add noise + } + + if want, ok := sch["const"]; ok && !o5deepEqual(want, val) { + *errs = append(*errs, fmt.Sprintf("%s: value %#v is not the required constant %#v", path, val, want)) + } + + if allowed, ok := sch["enum"].([]any); ok { + found := false + for _, cand := range allowed { + if o5deepEqual(cand, val) { + found = true + break + } + } + if !found { + *errs = append(*errs, fmt.Sprintf("%s: value %#v is not one of %v", path, val, allowed)) + } + } + + switch typed := val.(type) { + case map[string]any: + if required, ok := sch["required"].([]any); ok { + for _, r := range required { + name, _ := r.(string) + if _, present := typed[name]; !present { + *errs = append(*errs, fmt.Sprintf("%s: missing required key %q", path, name)) + } + } + } + + props, _ := sch["properties"].(map[string]any) + ap, apPresent := sch["additionalProperties"] + + for name, child := range typed { + if sub, ok := props[name].(map[string]any); ok { + o5validateNode(root, sub, child, path+"/"+name, errs) + continue + } + if !apPresent { + continue + } + switch policy := ap.(type) { + case bool: + if !policy { + *errs = append(*errs, fmt.Sprintf("%s: unknown key %q is not allowed here", path, name)) + } + case map[string]any: + o5validateNode(root, policy, child, path+"/"+name, errs) + } + } + + case []any: + if min, ok := o5number(sch["minItems"]); ok && float64(len(typed)) < min { + *errs = append(*errs, fmt.Sprintf("%s: has %d items, needs at least %d", path, len(typed), int(min))) + } + if max, ok := o5number(sch["maxItems"]); ok && float64(len(typed)) > max { + *errs = append(*errs, fmt.Sprintf("%s: has %d items, which is more than the %d allowed", path, len(typed), int(max))) + } + if unique, ok := sch["uniqueItems"].(bool); ok && unique { + for i := range typed { + for j := i + 1; j < len(typed); j++ { + if o5deepEqual(typed[i], typed[j]) { + *errs = append(*errs, fmt.Sprintf("%s: duplicate item %#v at %d and %d", path, typed[i], i, j)) + } + } + } + } + if items, ok := sch["items"].(map[string]any); ok { + for i, child := range typed { + o5validateNode(root, items, child, fmt.Sprintf("%s/%d", path, i), errs) + } + } + + case string: + if min, ok := o5number(sch["minLength"]); ok && float64(len([]rune(typed))) < min { + *errs = append(*errs, fmt.Sprintf("%s: %q is shorter than %d characters", path, typed, int(min))) + } + if max, ok := o5number(sch["maxLength"]); ok && float64(len([]rune(typed))) > max { + *errs = append(*errs, fmt.Sprintf("%s: string of %d characters is longer than the %d-character maximum", + path, len([]rune(typed)), int(max))) + } + if pattern, ok := sch["pattern"].(string); ok { + re, err := regexp.Compile(pattern) + if err != nil { + *errs = append(*errs, fmt.Sprintf("%s: schema pattern %q does not compile: %v", path, pattern, err)) + } else if !re.MatchString(typed) { + *errs = append(*errs, fmt.Sprintf("%s: %q does not match %q", path, typed, pattern)) + } + } + + default: + if num, isNum := o5number(val); isNum { + if min, ok := o5number(sch["minimum"]); ok && num < min { + *errs = append(*errs, fmt.Sprintf("%s: %v is below the minimum %v", path, num, min)) + } + if max, ok := o5number(sch["maximum"]); ok && num > max { + *errs = append(*errs, fmt.Sprintf("%s: %v is above the maximum %v", path, num, max)) + } + } + } +} + +func o5resolveRef(root map[string]any, ref string) (map[string]any, error) { + if !strings.HasPrefix(ref, "#/") { + return nil, fmt.Errorf("only local $ref is supported, got %q", ref) + } + cursor := any(root) + for _, segment := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + m, ok := cursor.(map[string]any) + if !ok { + return nil, fmt.Errorf("$ref %q: %q is not reachable", ref, segment) + } + cursor, ok = m[segment] + if !ok { + return nil, fmt.Errorf("$ref %q: no such member %q", ref, segment) + } + } + target, ok := cursor.(map[string]any) + if !ok { + return nil, fmt.Errorf("$ref %q does not point at a schema object", ref) + } + return target, nil +} + +func o5typeMatches(want string, val any) bool { + switch want { + case "object": + _, ok := val.(map[string]any) + return ok + case "array": + _, ok := val.([]any) + return ok + case "string": + _, ok := val.(string) + return ok + case "boolean": + _, ok := val.(bool) + return ok + case "null": + return val == nil + case "number": + _, ok := o5number(val) + return ok + case "integer": + n, ok := o5number(val) + return ok && n == float64(int64(n)) + default: + return false + } +} + +func o5typeOf(val any) string { + switch v := val.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case bool: + return "boolean" + case int64, float64: + return "number" + default: + return fmt.Sprintf("%T", v) + } +} + +func o5number(val any) (float64, bool) { + switch v := val.(type) { + case int64: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} + +func o5deepEqual(a, b any) bool { + an, aIsNum := o5number(a) + bn, bIsNum := o5number(b) + if aIsNum || bIsNum { + return aIsNum && bIsNum && an == bn + } + + switch av := a.(type) { + case nil: + return b == nil + case string: + bv, ok := b.(string) + return ok && av == bv + case bool: + bv, ok := b.(bool) + return ok && av == bv + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !o5deepEqual(av[i], bv[i]) { + return false + } + } + return true + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for k, v := range av { + other, present := bv[k] + if !present || !o5deepEqual(v, other) { + return false + } + } + return true + default: + return false + } +} + +func o5keys(m map[string]map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// Test-only YAML subset decoder +// --------------------------------------------------------------------------- + +type o5line struct { + num int + indent int + text string +} + +// o5yamlDecode decodes the YAML subset policy files are written in. Anything +// outside that subset -- tabs for indentation, anchors, multi-document streams, +// block scalars, duplicate keys -- is an error, never a guess. +func o5yamlDecode(src string) (any, error) { + lines, err := o5yamlScan(src) + if err != nil { + return nil, err + } + if len(lines) == 0 { + return nil, nil + } + return o5yamlBlock(lines) +} + +func o5yamlScan(src string) ([]o5line, error) { + var out []o5line + + for i, raw := range strings.Split(src, "\n") { + num := i + 1 + text := strings.TrimSuffix(raw, "\r") + + if strings.ContainsRune(text[:len(text)-len(strings.TrimLeft(text, " \t"))], '\t') { + return nil, fmt.Errorf("line %d: tab in indentation is not supported", num) + } + + text = o5stripComment(text) + trimmed := strings.TrimRight(text, " ") + if strings.TrimSpace(trimmed) == "" { + continue + } + if strings.TrimSpace(trimmed) == "---" || strings.TrimSpace(trimmed) == "..." { + return nil, fmt.Errorf("line %d: document markers are not supported", num) + } + + indent := len(trimmed) - len(strings.TrimLeft(trimmed, " ")) + out = append(out, o5line{num: num, indent: indent, text: strings.TrimLeft(trimmed, " ")}) + } + return out, nil +} + +// o5stripComment removes a trailing comment. A '#' only starts a comment when +// it is outside quotes and at the start of the line or preceded by a space, so +// a pattern like "**/#tag" survives. +func o5stripComment(text string) string { + var quote rune + for i, r := range text { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + case r == '#': + if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' { + return text[:i] + } + } + } + return text +} + +func o5yamlBlock(lines []o5line) (any, error) { + if len(lines) == 0 { + return nil, nil + } + base := lines[0].indent + for _, ln := range lines { + if ln.indent < base { + return nil, fmt.Errorf("line %d: indent %d is shallower than the block's %d", ln.num, ln.indent, base) + } + } + if o5isSeqItem(lines[0].text) { + return o5yamlSequence(lines, base) + } + return o5yamlMapping(lines, base) +} + +func o5isSeqItem(text string) bool { + return text == "-" || strings.HasPrefix(text, "- ") +} + +func o5yamlSequence(lines []o5line, base int) ([]any, error) { + out := []any{} + + for i := 0; i < len(lines); { + ln := lines[i] + if ln.indent != base { + return nil, fmt.Errorf("line %d: expected a sequence item at indent %d", ln.num, base) + } + if !o5isSeqItem(ln.text) { + return nil, fmt.Errorf("line %d: expected %q to start with %q", ln.num, ln.text, "- ") + } + + end := i + 1 + for end < len(lines) && lines[end].indent > base { + end++ + } + + after := ln.text[1:] + rest := strings.TrimLeft(after, " ") + restIndent := ln.indent + 1 + (len(after) - len(rest)) + + var ( + item any + err error + ) + switch { + case rest == "": + item, err = o5yamlBlock(lines[i+1 : end]) + case o5isMappingEntry(rest): + sub := make([]o5line, 0, end-i) + sub = append(sub, o5line{num: ln.num, indent: restIndent, text: rest}) + sub = append(sub, lines[i+1:end]...) + item, err = o5yamlBlock(sub) + default: + if end > i+1 { + return nil, fmt.Errorf("line %d: a scalar sequence item cannot have child lines", ln.num) + } + item, err = o5yamlValue(rest, ln.num) + } + if err != nil { + return nil, err + } + + out = append(out, item) + i = end + } + return out, nil +} + +func o5yamlMapping(lines []o5line, base int) (map[string]any, error) { + out := map[string]any{} + + for i := 0; i < len(lines); { + ln := lines[i] + if ln.indent != base { + return nil, fmt.Errorf("line %d: indent %d does not line up with the mapping's %d", ln.num, ln.indent, base) + } + + key, rest, ok := o5splitKey(ln.text) + if !ok { + return nil, fmt.Errorf("line %d: %q is not a mapping entry", ln.num, ln.text) + } + if _, dup := out[key]; dup { + return nil, fmt.Errorf("line %d: duplicate key %q", ln.num, key) + } + + end := i + 1 + for end < len(lines) && lines[end].indent > base { + end++ + } + + var ( + val any + err error + ) + if rest != "" { + if end > i+1 { + return nil, fmt.Errorf("line %d: key %q has both an inline value and child lines", ln.num, key) + } + val, err = o5yamlValue(rest, ln.num) + } else { + val, err = o5yamlBlock(lines[i+1 : end]) + } + if err != nil { + return nil, err + } + + out[key] = val + i = end + } + return out, nil +} + +func o5isMappingEntry(text string) bool { + _, _, ok := o5splitKey(text) + return ok +} + +// o5splitKey splits "key: value" at the first top-level colon. The colon must +// end the line or be followed by a space, which is what keeps a bare scalar +// containing a colon from being misread as a key. +func o5splitKey(text string) (key, rest string, ok bool) { + var quote rune + depth := 0 + + for i, r := range text { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + case r == '[' || r == '{': + depth++ + case r == ']' || r == '}': + depth-- + case r == ':' && depth == 0: + if i+1 < len(text) && text[i+1] != ' ' { + return "", "", false + } + key = strings.TrimSpace(text[:i]) + if unquoted, wasQuoted := o5unquote(key); wasQuoted { + key = unquoted + } + if key == "" { + return "", "", false + } + return key, strings.TrimSpace(text[i+1:]), true + } + } + return "", "", false +} + +func o5yamlValue(text string, line int) (any, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, nil + } + if text[0] == '[' || text[0] == '{' { + flow := &o5flow{src: []rune(text), line: line} + val, err := flow.value() + if err != nil { + return nil, err + } + flow.skipSpace() + if flow.pos != len(flow.src) { + return nil, fmt.Errorf("line %d: trailing text after flow collection: %q", line, string(flow.src[flow.pos:])) + } + return val, nil + } + return o5scalar(text, line) +} + +type o5flow struct { + src []rune + pos int + line int +} + +func (f *o5flow) skipSpace() { + for f.pos < len(f.src) && (f.src[f.pos] == ' ' || f.src[f.pos] == '\t') { + f.pos++ + } +} + +func (f *o5flow) value() (any, error) { + f.skipSpace() + if f.pos >= len(f.src) { + return nil, fmt.Errorf("line %d: unexpected end of flow collection", f.line) + } + switch f.src[f.pos] { + case '[': + return f.sequence() + case '{': + return f.mapping() + default: + return o5scalar(f.token(), f.line) + } +} + +// token reads a bare or quoted token, stopping at a flow delimiter. +func (f *o5flow) token() string { + f.skipSpace() + start := f.pos + + if f.pos < len(f.src) && (f.src[f.pos] == '"' || f.src[f.pos] == '\'') { + quote := f.src[f.pos] + f.pos++ + for f.pos < len(f.src) { + if f.src[f.pos] == '\\' && quote == '"' && f.pos+1 < len(f.src) { + f.pos += 2 + continue + } + if f.src[f.pos] == quote { + f.pos++ + break + } + f.pos++ + } + return string(f.src[start:f.pos]) + } + + for f.pos < len(f.src) && !strings.ContainsRune(",[]{}:", f.src[f.pos]) { + f.pos++ + } + return strings.TrimSpace(string(f.src[start:f.pos])) +} + +func (f *o5flow) sequence() ([]any, error) { + f.pos++ // '[' + out := []any{} + + for { + f.skipSpace() + if f.pos >= len(f.src) { + return nil, fmt.Errorf("line %d: unterminated flow sequence", f.line) + } + if f.src[f.pos] == ']' { + f.pos++ + return out, nil + } + + item, err := f.value() + if err != nil { + return nil, err + } + out = append(out, item) + + f.skipSpace() + if f.pos >= len(f.src) { + return nil, fmt.Errorf("line %d: unterminated flow sequence", f.line) + } + switch f.src[f.pos] { + case ',': + f.pos++ + case ']': + f.pos++ + return out, nil + default: + return nil, fmt.Errorf("line %d: expected %q or %q in flow sequence, got %q", f.line, ",", "]", string(f.src[f.pos])) + } + } +} + +func (f *o5flow) mapping() (map[string]any, error) { + f.pos++ // '{' + out := map[string]any{} + + for { + f.skipSpace() + if f.pos >= len(f.src) { + return nil, fmt.Errorf("line %d: unterminated flow mapping", f.line) + } + if f.src[f.pos] == '}' { + f.pos++ + return out, nil + } + + key := f.token() + if unquoted, wasQuoted := o5unquote(key); wasQuoted { + key = unquoted + } + if key == "" { + return nil, fmt.Errorf("line %d: empty key in flow mapping", f.line) + } + if _, dup := out[key]; dup { + return nil, fmt.Errorf("line %d: duplicate key %q in flow mapping", f.line, key) + } + + f.skipSpace() + if f.pos >= len(f.src) || f.src[f.pos] != ':' { + return nil, fmt.Errorf("line %d: expected %q after key %q in flow mapping", f.line, ":", key) + } + f.pos++ + + val, err := f.value() + if err != nil { + return nil, err + } + out[key] = val + + f.skipSpace() + if f.pos >= len(f.src) { + return nil, fmt.Errorf("line %d: unterminated flow mapping", f.line) + } + switch f.src[f.pos] { + case ',': + f.pos++ + case '}': + f.pos++ + return out, nil + default: + return nil, fmt.Errorf("line %d: expected %q or %q in flow mapping, got %q", f.line, ",", "}", string(f.src[f.pos])) + } + } +} + +var ( + o5intPattern = regexp.MustCompile(`^-?[0-9]+$`) + o5floatPattern = regexp.MustCompile(`^-?[0-9]+\.[0-9]+$`) +) + +func o5scalar(text string, line int) (any, error) { + text = strings.TrimSpace(text) + + if unquoted, wasQuoted := o5unquote(text); wasQuoted { + return unquoted, nil + } + + switch text { + case "", "null", "~": + return nil, nil + case "true": + return true, nil + case "false": + return false, nil + } + + if o5intPattern.MatchString(text) { + n, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: %q looks like an integer but does not parse: %v", line, text, err) + } + return n, nil + } + if o5floatPattern.MatchString(text) { + n, err := strconv.ParseFloat(text, 64) + if err != nil { + return nil, fmt.Errorf("line %d: %q looks like a float but does not parse: %v", line, text, err) + } + return n, nil + } + return text, nil +} + +// o5unquote strips one layer of quoting, reporting whether the input was +// quoted at all. Only the escapes policy files plausibly use are handled. +func o5unquote(text string) (string, bool) { + if len(text) < 2 { + return text, false + } + + switch { + case text[0] == '\'' && text[len(text)-1] == '\'': + return strings.ReplaceAll(text[1:len(text)-1], "''", "'"), true + + case text[0] == '"' && text[len(text)-1] == '"': + body := text[1 : len(text)-1] + var b strings.Builder + for i := 0; i < len(body); i++ { + if body[i] == '\\' && i+1 < len(body) { + i++ + switch body[i] { + case 'n': + b.WriteByte('\n') + case 't': + b.WriteByte('\t') + default: + b.WriteByte(body[i]) + } + continue + } + b.WriteByte(body[i]) + } + return b.String(), true + } + return text, false +} diff --git a/internal/policy/semver.go b/internal/policy/semver.go new file mode 100644 index 0000000..63eecda --- /dev/null +++ b/internal/policy/semver.go @@ -0,0 +1,617 @@ +// semver.go is step O.7: computing the semantic-version bump that a git tag +// represents, so that a policy rule's `matchSemverBump` key has something +// TRUSTWORTHY to match against. +// +// --------------------------------------------------------------------------- +// WHY THIS IS COMPUTED AND NOT READ +// --------------------------------------------------------------------------- +// +// research/09-orchestration-and-github-actions.md Recommendation 2: +// +// "`matchSemverBump` must be computed by Anvil, not read from the event: +// GitHub's payload has no 'previous tag'. Anvil derives it with +// `git describe --tags --abbrev=0 ^` and therefore the Action must set +// `actions/checkout` `fetch-depth: 0` and `fetch-tags: true`. This is a real +// operational footgun worth documenting loudly." +// +// So there is no field to read. A `push` event on `refs/tags/v2.0.0` says the +// tag exists; it does not say what came before it, and "what came before it" is +// the entire content of a bump kind. Nothing in this file consults an event +// payload, and nothing in it accepts a caller-supplied bump. +// +// --------------------------------------------------------------------------- +// THE FOOTGUN, AND WHY THIS FILE IS STRICTER THAN THE HEURISTIC IT WAS GIVEN +// --------------------------------------------------------------------------- +// +// O.7's packet suggests detecting the shallow-checkout footgun AFTER the fact: +// treat a `git describe` failure as ErrShallowCheckout when `.git/shallow` +// exists. That heuristic has a hole, and the hole is the dangerous direction. +// +// A shallow checkout does not necessarily make `git describe` FAIL. `--depth 3` +// on the fixture in semver_test.go still reaches `v1.1.1`, so a post-hoc check +// would answer "major" for `v2.0.0` and never fire the sentinel. The next +// release, cut after four quiet commits, would fall off the end of the same +// depth-3 window and answer "no previous tag" -- or, worse, find an older tag +// and answer "minor" for a major release. The depth of the checkout would be +// silently deciding whether a major release gets its full SAST+DAST scan. +// +// Truncated history can only ever REMOVE candidate tags, never invent a nearer +// one, so a shallow answer is either right or too old -- and "too old" is +// indistinguishable from right at the call site. This file therefore refuses to +// answer at all on a shallow checkout: ComputeSemverBump checks shallowness +// FIRST, before it looks at tags, and returns ErrShallowCheckout unconditionally. +// A loud, deterministic error on every shallow run is the behaviour that gets +// `fetch-depth: 0` added to the workflow; a plausible-looking answer is not. +// +// --------------------------------------------------------------------------- +// SCOPE: THIS IS SEMVER, AND SEMVER ONLY +// --------------------------------------------------------------------------- +// +// parseSemver implements https://semver.org 2.0.0 and NOTHING ELSE. It is +// deliberately unexported, because the one thing that must not happen to it is +// being borrowed as a general version comparator. +// +// It is NOT a Debian version comparator (epochs, `~`, and the alternating +// digit/non-digit ordering of `deb-version(7)` are a different algorithm and a +// different order). It is NOT an RPM comparator (`rpmvercmp`, epoch/version/ +// release triples, `tilde` and `caret` markers). It is NOT a Maven version +// comparator or range parser (`[1.0,2.0)`, qualifier ordering, `-SNAPSHOT`). +// It is NOT NuGet, Python PEP 440, or Go's `+incompatible`. Using this code to +// decide whether a package version falls inside a CVE's affected range would +// produce silently wrong matches, which is the worst failure mode a vulnerability +// scanner has. Those ecosystems need their own comparators, owned and tested +// separately, and this file does not provide them. +// +// What this file matches is a GIT TAG in a repository Anvil is scanning, against +// the semver vocabulary the policy schema already froze. That is the whole scope. + +package policy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +var ( + // ErrShallowCheckout reports that the repository's history is truncated, + // so no statement about the previous tag can be trusted. It is the + // sentinel for the documented operational footgun and it is returned + // whenever the repository is shallow -- including when `git describe` + // would have produced a plausible answer. See this file's header for why + // answering anyway is the unsafe direction. + // + // The fix is always the same and the message says it: the Action must + // check out with `fetch-depth: 0` and `fetch-tags: true`. + ErrShallowCheckout = errors.New("policy: shallow checkout: cannot compute a semver bump") + + // ErrNoPreviousTag reports that the history is complete but carries no + // earlier version tag to compare against -- the ordinary case for a + // repository's first release. It is a normal outcome, not a failure, and + // the caller decides what an unclassifiable tag means. It is DISTINCT + // from ErrShallowCheckout on purpose: "there is nothing before this tag" + // and "we cannot see what is before this tag" call for opposite + // responses, and collapsing them is how a misconfigured checkout gets + // mistaken for a first release forever. + ErrNoPreviousTag = errors.New("policy: no previous version tag") + + // ErrNotSemver reports a tag that is not a semantic version. The policy + // schema's `matchSemverBump` vocabulary is semver's, so a tag outside it + // has no bump kind -- not a defaulted one. + ErrNotSemver = errors.New("policy: tag is not a semantic version") + + // ErrTagNotFound reports that the named tag does not resolve to a commit + // in the repository. Separated from ErrNoPreviousTag so a typo in the + // caller's ref does not masquerade as a first release. + ErrTagNotFound = errors.New("policy: tag not found in repository") + + // ErrGit reports that git itself could not be run or failed for a reason + // this file does not model. The underlying stderr is wrapped in, never + // discarded: "git error" with no text is the failure mode O.7's packet + // forbids. + ErrGit = errors.New("policy: git invocation failed") +) + +// --------------------------------------------------------------------------- +// Tunables, both of which are guards rather than budgets +// --------------------------------------------------------------------------- + +// gitTimeout bounds a single git invocation so a wedged git cannot hang the +// daemon that called us. +// +// DERIVATION: this is NOT a performance budget and nothing should be tuned +// against it. Every command this file runs (`rev-parse`, `describe`) is local +// and reads only the object database and refs; on the largest repositories in +// public use these are sub-second. 30s is chosen to sit orders of magnitude +// above any plausible local latency so that expiry means "git is stuck", never +// "this repository is big". If it ever fires on a healthy repository, the +// correct response is to investigate the hang, not to raise the number. +const gitTimeout = 30 * time.Second + +// maxTagWalk bounds the walk back through tags that carry no version signal +// (see ComputeSemverBump). It exists so a repository that tags every commit +// with a non-version name cannot turn one policy evaluation into an unbounded +// series of git invocations. +// +// DERIVATION: not measured, and deliberately generous. A release lineage needs +// one step; the walk only takes another when it meets a tag that is not a +// semantic version or that carries the same version core as the new tag. 256 +// consecutive such tags is already a pathological repository, and the error +// returned at the bound says so rather than pretending there is no previous tag. +const maxTagWalk = 256 + +// --------------------------------------------------------------------------- +// ComputeSemverBump +// --------------------------------------------------------------------------- + +// ComputeSemverBump reports which kind of version bump newTag represents in the +// git repository rooted at repoPath. +// +// The returned BumpKind is the type engine.go owns -- schemas/policy.schema.json +// #/$defs/semverBump's one Go image. This file declares no second enum and +// returns no token that is not in BumpKindValues(). +// +// THE RULE, STATED NORMATIVELY +// +// 1. newTag must parse as a semantic version (an optional leading `v`, then +// semver 2.0.0). Otherwise ErrNotSemver. +// 2. If the repository is shallow, ErrShallowCheckout -- always, before any +// tag is looked at. See this file's header. +// 3. If newTag carries a PRERELEASE identifier (`v2.0.0-rc.1`), the bump is +// BumpPrerelease. A prerelease tag is by definition not a release; whatever +// core version it names, that version has not shipped. This needs no +// history and consults none. +// 4. Otherwise walk back from newTag with +// `git describe --tags --abbrev=0 ^`, exactly the command research/09 +// specifies, and classify newTag against the first tag found that carries a +// DIFFERENT version core: +// - major differs -> BumpMajor +// - else minor differs -> BumpMinor +// - else patch differs -> BumpPatch +// +// Step 4 skips two kinds of tag, and skipping is the whole reason it is a walk +// and not a single command: +// +// - A tag that is not a semantic version. `git describe --tags` matches every +// tag, including `nightly-2026-08-09`. Erroring on one would mean a routine +// nightly tag silently stops release scans; classifying against one is +// impossible. It carries no version signal, so it is stepped over. +// - A tag whose version CORE (major.minor.patch) equals newTag's. `v2.0.0-rc.1` +// immediately before `v2.0.0` is the standard release-candidate flow, and it +// says nothing about the size of the bump. Stopping there would leave a real +// major release unclassifiable; stepping over it finds `v1.9.3` and answers +// BumpMajor, which is the answer the operator means. +// +// If the walk runs out of tags, ErrNoPreviousTag. If it exceeds maxTagWalk, an +// error wrapping ErrNoPreviousTag that says so. +// +// WHAT THIS DOES NOT CLAIM. The bump names the most significant core component +// that CHANGED between the two tags. It does not assert that newTag is greater +// than the tag it was compared with: a tag placed on a descendant of a higher +// version (a mis-cut release) is classified by the same rule, and detecting +// non-monotonic tagging is a different job with a different owner. +// +// ECOSYSTEM SCOPE. Semver only. Debian, RPM, Maven, PEP 440 and NuGet version +// ordering are different algorithms and none of them is implemented here -- see +// this file's header before reaching for parseSemver. +func ComputeSemverBump(repoPath, newTag string) (BumpKind, error) { + if repoPath == "" { + return BumpNone, fmt.Errorf("%w: no repository path given", ErrGit) + } + if newTag == "" { + return BumpNone, fmt.Errorf("%w: no tag given", ErrNotSemver) + } + + next, err := parseSemver(newTag) + if err != nil { + return BumpNone, err + } + + // Step 2 -- before any tag is consulted, so the answer never depends on + // how deep the checkout happened to be. + shallow, err := isShallowRepository(repoPath) + if err != nil { + return BumpNone, err + } + if shallow { + return BumpNone, fmt.Errorf( + "%w: %s has truncated history, so the tag before %q cannot be determined. "+ + "actions/checkout must set `fetch-depth: 0` and `fetch-tags: true` "+ + "(research/09-orchestration-and-github-actions.md Recommendation 2)", + ErrShallowCheckout, repoPath, newTag) + } + + // Step 3 -- a prerelease tag classifies itself. + if next.prerelease != "" { + return BumpPrerelease, nil + } + + // Step 4 -- the walk. newTag is verified to exist first so that a failure + // of `^` below can only mean "rev is the root commit", and a typo'd + // ref reports itself rather than looking like a first release. + if err := verifyRev(repoPath, newTag); err != nil { + return BumpNone, err + } + + rev := newTag + for i := 0; i < maxTagWalk; i++ { + prevTag, err := describePreviousTag(repoPath, rev) + if err != nil { + return BumpNone, err + } + + prev, perr := parseSemver(prevTag) + if perr != nil { + rev = prevTag // not a version tag: no signal, step over it + continue + } + kind := classifyCore(prev, next) + if kind == BumpNone { + rev = prevTag // same core (e.g. the rc of this very release) + continue + } + return kind, nil + } + + return BumpNone, fmt.Errorf( + "%w: walked back %d tags from %q without finding one that carries a different "+ + "version core; refusing to walk further", + ErrNoPreviousTag, maxTagWalk, newTag) +} + +// classifyCore returns the bump kind implied by the two version cores, or +// BumpNone when the cores are identical. +// +// BumpNone is not a fifth bump kind (engine.go says so where it is declared); +// here it is the "these two tags carry the same version, keep looking" signal, +// which is why this function and the walk above are written as one pair. +func classifyCore(prev, next semver) BumpKind { + switch { + case prev.major != next.major: + return BumpMajor + case prev.minor != next.minor: + return BumpMinor + case prev.patch != next.patch: + return BumpPatch + default: + return BumpNone + } +} + +// --------------------------------------------------------------------------- +// git plumbing +// --------------------------------------------------------------------------- + +// isShallowRepository reports whether repoPath's history is truncated. +// +// Two mechanisms, in order, because the first is authoritative and the second +// is what O.7's packet names: +// +// 1. `git rev-parse --is-shallow-repository`, which git answers correctly for +// worktrees, submodules and separate git dirs alike. +// 2. failing that (an ancient git that does not know the option), locate the +// git directory with `git rev-parse --git-dir` and probe for the `shallow` +// file inside it -- the `.git/shallow` heuristic, generalised so it also +// works when `.git` is a FILE pointing elsewhere, which is exactly what a +// worktree or a submodule checkout looks like. +// +// If neither can be answered, the error is returned rather than defaulting. +// Defaulting to "not shallow" would resurrect the footgun this file exists to +// close, and defaulting to "shallow" would fail every scan on an unrelated git +// problem. +func isShallowRepository(repoPath string) (bool, error) { + out, stderr, err := runGit(repoPath, "rev-parse", "--is-shallow-repository") + if err == nil { + switch out { + case "true": + return true, nil + case "false": + return false, nil + } + // Some very old gits echo the option back instead of answering. Fall + // through to the file probe rather than guessing from unknown text. + } + + gitDir, stderr2, err2 := runGit(repoPath, "rev-parse", "--git-dir") + if err2 != nil { + // Report the FIRST failure, which is the more specific one, and keep + // both stderrs: this is also the path a non-repository takes. + if err != nil { + return false, gitError(err, "rev-parse --is-shallow-repository", repoPath, stderr) + } + return false, gitError(err2, "rev-parse --git-dir", repoPath, stderr2) + } + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + if _, statErr := os.Stat(filepath.Join(gitDir, "shallow")); statErr == nil { + return true, nil + } else if !errors.Is(statErr, os.ErrNotExist) { + return false, fmt.Errorf("%w: probing %s for a shallow marker: %v", + ErrGit, gitDir, statErr) + } + return false, nil +} + +// verifyRev checks that rev names a commit. `^{commit}` is peeled explicitly so +// an annotated tag object resolves the same way a lightweight one does. +func verifyRev(repoPath, rev string) error { + _, stderr, err := runGit(repoPath, "rev-parse", "--verify", "--quiet", rev+"^{commit}") + if err == nil { + return nil + } + if _, ok := exitStatus(err); ok { + return fmt.Errorf("%w: %q does not resolve to a commit in %s", ErrTagNotFound, rev, repoPath) + } + return gitError(err, "rev-parse --verify "+rev, repoPath, stderr) +} + +// noPreviousTagMarkers are the stderr fragments git uses when the question +// "what is the nearest tag before this revision?" has no answer, as opposed to +// git failing. +// +// These are matched case-insensitively against stderr. Matching on message text +// is not something to do casually, so the fallback is the safe one: anything +// unrecognised becomes ErrGit WITH the stderr attached, never a silent +// "no previous tag". +var noPreviousTagMarkers = []string{ + "no names found", // no tags anywhere in the repository + "no tags can describe", // tags exist, none is an ancestor + "cannot describe", // older phrasings of both + "unknown revision", // `^` -- there is no earlier commit + "ambiguous argument", // same, phrased differently + "not a valid object name", +} + +// describePreviousTag runs the command research/09 specifies: +// +// git describe --tags --abbrev=0 ^ +// +// `--tags` so lightweight tags count, `--abbrev=0` so the bare tag name comes +// back rather than a `tag-N-gsha` description, and `^` so the tag on rev +// itself is excluded. +func describePreviousTag(repoPath, rev string) (string, error) { + out, stderr, err := runGit(repoPath, "describe", "--tags", "--abbrev=0", rev+"^") + if err == nil { + if out == "" { + return "", fmt.Errorf("%w: `git describe` before %q returned nothing", ErrNoPreviousTag, rev) + } + return out, nil + } + if _, ok := exitStatus(err); ok { + lower := strings.ToLower(stderr) + for _, marker := range noPreviousTagMarkers { + if strings.Contains(lower, marker) { + return "", fmt.Errorf( + "%w: nothing tagged before %q in %s. If this is not the repository's first "+ + "version tag, the checkout may not have fetched tags: actions/checkout "+ + "needs `fetch-tags: true` (git said: %s)", + ErrNoPreviousTag, rev, repoPath, oneLine(stderr)) + } + } + } + return "", gitError(err, "describe --tags --abbrev=0 "+rev+"^", repoPath, stderr) +} + +// runGit runs one git command against repoPath and returns trimmed stdout, +// trimmed stderr, and the process error. +// +// The environment is inherited so the caller's git configuration applies, with +// three additions: +// +// - git must never block on a credential prompt (this runs in a daemon); +// - it must not take the index lock for what are read-only queries; +// - its messages must be in the C locale. describePreviousTag distinguishes +// "there is no earlier tag" from "git failed" by reading stderr, and git +// built with NLS translates those sentences. Under a German or Japanese +// LANG the markers would stop matching and an ordinary first release would +// be reported as ErrGit. That is a loud, correct-category-wrong error +// rather than a wrong answer, but pinning the locale removes it entirely. +// LANGUAGE is cleared too because GNU gettext lets it override LC_ALL. +func runGit(repoPath string, args ...string) (stdout, stderr string, err error) { + ctx, cancel := context.WithTimeout(context.Background(), gitTimeout) + defer cancel() + + full := append([]string{"-C", repoPath}, args...) + cmd := exec.CommandContext(ctx, "git", full...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + cmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_OPTIONAL_LOCKS=0", + "LC_ALL=C", + "LANGUAGE=", + ) + + err = cmd.Run() + if ctxErr := ctx.Err(); ctxErr != nil { + err = fmt.Errorf("git %s: %w after %s", strings.Join(args, " "), ctxErr, gitTimeout) + } + return strings.TrimSpace(outBuf.String()), strings.TrimSpace(errBuf.String()), err +} + +// exitStatus reports the process exit code when err is git exiting non-zero, +// and false when git could not be run at all (missing binary, timeout, ...). +// The distinction matters: "git said no" and "there is no git" are different +// diagnoses and this file must not merge them. +func exitStatus(err error) (int, bool) { + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode(), true + } + return 0, false +} + +// gitError wraps a git failure with the command, the repository and git's own +// stderr. O.7's packet forbids a bare "git error"; this is why the stderr is +// carried all the way out. +func gitError(err error, what, repoPath, stderr string) error { + if stderr == "" { + return fmt.Errorf("%w: `git %s` in %s: %v", ErrGit, what, repoPath, err) + } + return fmt.Errorf("%w: `git %s` in %s: %v: %s", ErrGit, what, repoPath, err, oneLine(stderr)) +} + +// oneLine flattens git's multi-line stderr so an error stays greppable in a log. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// --------------------------------------------------------------------------- +// semver 2.0.0, and nothing else +// --------------------------------------------------------------------------- + +// semver is a parsed semantic version. Unexported on purpose -- see this file's +// header: exporting it would invite it to be used as a package-ecosystem +// version comparator, which it is not. +type semver struct { + major, minor, patch uint64 + prerelease string // without the leading '-'; "" when absent + build string // without the leading '+'; "" when absent +} + +// parseSemver parses a git tag as a semantic version. +// +// The dialect, stated so it is a contract and not an accident: +// +// - an optional leading `v`, lowercase only, because that is git's tagging +// convention and accepting a second spelling means two spellings of the +// same tag can disagree. `1.2.3` and `v1.2.3` both parse; `V1.2.3` does not. +// - `MAJOR.MINOR.PATCH`, all three REQUIRED. `v1.2` is not a semantic version +// and is not silently widened to `v1.2.0`: the walk in ComputeSemverBump +// steps over tags it cannot parse, so a repository using two-component tags +// gets ErrNoPreviousTag rather than a fabricated patch component. +// - numeric identifiers carry NO leading zeroes and no sign (semver 2.0.0 §2), +// so `v01.0.0` is rejected. This is what makes the parse total-order-safe: +// `01` and `1` cannot both exist as distinct tags meaning the same version. +// - an optional `-prerelease`, dot-separated identifiers of [0-9A-Za-z-], +// none empty, numeric ones without leading zeroes (§9). +// - an optional `+build`, dot-separated identifiers of [0-9A-Za-z-], none +// empty (§10). Build metadata is PARSED and then ignored for classification, +// exactly as the spec requires: it carries no precedence. +// +// Every rejection names what was wrong with the tag, because this error reaches +// an operator looking at a tag they just pushed. +func parseSemver(tag string) (semver, error) { + bad := func(reason string) (semver, error) { + return semver{}, fmt.Errorf("%w: %q: %s", ErrNotSemver, tag, reason) + } + + rest := strings.TrimPrefix(tag, "v") + + // Split off build metadata first: '+' cannot appear in a prerelease, so + // the leftmost '+' ends the version proper. + var build string + if i := strings.IndexByte(rest, '+'); i >= 0 { + build = rest[i+1:] + rest = rest[:i] + if err := checkDotIdentifiers(build, false); err != nil { + return bad("build metadata: " + err.Error()) + } + } + + // Then the prerelease. The FIRST '-' after the core starts it. + var prerelease string + if i := strings.IndexByte(rest, '-'); i >= 0 { + prerelease = rest[i+1:] + rest = rest[:i] + if err := checkDotIdentifiers(prerelease, true); err != nil { + return bad("prerelease: " + err.Error()) + } + } + + parts := strings.Split(rest, ".") + if len(parts) != 3 { + return bad(fmt.Sprintf("want major.minor.patch, got %d component(s) in %q", len(parts), rest)) + } + var nums [3]uint64 + for i, name := range coreComponentNames { + n, err := parseNumericIdentifier(parts[i]) + if err != nil { + return bad(name + ": " + err.Error()) + } + nums[i] = n + } + + return semver{ + major: nums[0], + minor: nums[1], + patch: nums[2], + prerelease: prerelease, + build: build, + }, nil +} + +// coreComponentNames labels the three core components in a parse error, for an +// operator reading it against a tag they just pushed. +// +// They are spelled "... version" rather than bare "major"/"minor"/"patch" +// because those three bare words are ALSO the BumpKind tokens engine.go owns, +// and TestSemverFileDeclaresNoSecondBumpVocabulary -- correctly -- cannot tell a +// component label from a forked enum. Two vocabularies sharing three words is +// exactly the collision plan/IMPLEMENTATION-PLAN.md section 6 is about, so the +// one that is not the enum gets the longer spelling. +var coreComponentNames = [3]string{"major version", "minor version", "patch version"} + +// parseNumericIdentifier parses one semver numeric identifier: digits only, no +// sign, no leading zero unless the identifier IS "0". +func parseNumericIdentifier(s string) (uint64, error) { + if s == "" { + return 0, errors.New("empty") + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, fmt.Errorf("%q is not a number", s) + } + } + if len(s) > 1 && s[0] == '0' { + return 0, fmt.Errorf("%q has a leading zero", s) + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("%q does not fit in a 64-bit unsigned integer", s) + } + return n, nil +} + +// checkDotIdentifiers validates a dot-separated identifier list. numericRules +// applies semver §9's extra constraint on prerelease identifiers that are +// wholly numeric; build metadata (§10) has no such rule, so `+001` is legal +// build metadata and `-001` is not a legal prerelease. +func checkDotIdentifiers(s string, numericRules bool) error { + if s == "" { + return errors.New("empty") + } + for _, id := range strings.Split(s, ".") { + if id == "" { + return errors.New("empty identifier") + } + allDigits := true + for i := 0; i < len(id); i++ { + c := id[i] + switch { + case c >= '0' && c <= '9': + case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-': + allDigits = false + default: + return fmt.Errorf("identifier %q contains %q, which is not [0-9A-Za-z-]", id, string(c)) + } + } + if numericRules && allDigits && len(id) > 1 && id[0] == '0' { + return fmt.Errorf("numeric identifier %q has a leading zero", id) + } + } + return nil +} diff --git a/internal/policy/semver_test.go b/internal/policy/semver_test.go new file mode 100644 index 0000000..08a864a --- /dev/null +++ b/internal/policy/semver_test.go @@ -0,0 +1,524 @@ +package policy + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// The fixture repository +// --------------------------------------------------------------------------- +// +// One linear history, built by real git, covering every branch of the walk: +// +// c1 v1.0.0 root commit -- nothing before it +// c2 v1.1.0 minor +// c3 v1.1.1 patch +// c4 v2.0.0 major +// c5 nightly-2026-08-09 NOT a semantic version -- must be stepped over +// c6 v2.1.0 minor, computed across the nightly tag +// c7 v2.1.1-rc.1 prerelease +// c8 v2.1.1 patch, computed across its own release candidate +// +// The packet requires v1.0.0 -> v1.1.0 -> v1.1.1 -> v2.0.0; the rest exists +// because those four alone never exercise the two skip rules, and a walk whose +// skips are untested is a walk that will be deleted by the next refactor. + +type fixtureStep struct { + tag string + note string +} + +var fixtureHistory = []fixtureStep{ + {"v1.0.0", "root"}, + {"v1.1.0", ""}, + {"v1.1.1", ""}, + {"v2.0.0", ""}, + {"nightly-2026-08-09", "not a semantic version"}, + {"v2.1.0", ""}, + {"v2.1.1-rc.1", ""}, + {"v2.1.1", ""}, +} + +// requireGit skips the test when git is not on PATH. Everything else in this +// file is a real git invocation, so there is nothing to fall back to. +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not on PATH; O.7's behaviour is defined only in terms of real git") + } +} + +// fixtureEnv is os.Environ() with git's configuration neutralised, so the +// fixture is the same repository on every developer's machine. A global +// commit.gpgsign, a global user.name, or a system-wide template would +// otherwise leak into it. Go's exec deduplicates the environment keeping the +// LAST occurrence, so appending is an override. +func fixtureEnv(t *testing.T) []string { + t.Helper() + home := t.TempDir() + return append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL="+filepath.Join(home, "no-such-gitconfig"), + "GIT_CONFIG_SYSTEM="+filepath.Join(home, "no-such-gitconfig"), + "GIT_AUTHOR_NAME=Anvil Fixture", + "GIT_AUTHOR_EMAIL=fixture@anvil.invalid", + "GIT_COMMITTER_NAME=Anvil Fixture", + "GIT_COMMITTER_EMAIL=fixture@anvil.invalid", + "GIT_AUTHOR_DATE=2026-01-01T00:00:00+00:00", + "GIT_COMMITTER_DATE=2026-01-01T00:00:00+00:00", + "GIT_TERMINAL_PROMPT=0", + ) +} + +func git(t *testing.T, env []string, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s (in %s): %v\n%s", strings.Join(args, " "), dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +// newFixtureRepo builds the history above and returns its path. +func newFixtureRepo(t *testing.T) string { + t.Helper() + requireGit(t) + + env := fixtureEnv(t) + root := t.TempDir() + repo := filepath.Join(root, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("creating the fixture directory: %v", err) + } + + git(t, env, repo, "-c", "init.defaultBranch=main", "init", "--quiet") + for i, step := range fixtureHistory { + git(t, env, repo, "commit", "--allow-empty", "--quiet", + "-m", fmt.Sprintf("commit %d for %s", i+1, step.tag)) + git(t, env, repo, "tag", step.tag) + } + return repo +} + +// fileURL renders a local path as the file:// URL git needs for a clone that +// honours --depth. `git clone --depth` against a bare path is silently ignored +// ("--depth is ignored in local clones"), which would produce a NON-shallow +// fixture and a test that passes for the wrong reason. +func fileURL(path string) string { + slashed := filepath.ToSlash(path) + if !strings.HasPrefix(slashed, "/") { + slashed = "/" + slashed // C:/... -> /C:/... + } + return "file://" + strings.ReplaceAll(slashed, " ", "%20") +} + +// newShallowClone clones the fixture truncated to depth commits, checked out at +// tag. +func newShallowClone(t *testing.T, src, tag string, depth int) string { + t.Helper() + env := fixtureEnv(t) + dst := filepath.Join(t.TempDir(), "shallow") + git(t, env, "", "clone", "--quiet", + "--depth", strconv.Itoa(depth), "--branch", tag, + fileURL(src), dst) + return dst +} + +// --------------------------------------------------------------------------- +// The required scenario: a real tag sequence, every bump classified +// --------------------------------------------------------------------------- + +func TestComputeSemverBump_TagSequence(t *testing.T) { + repo := newFixtureRepo(t) + + cases := []struct { + tag string + want BumpKind + why string + }{ + {"v1.1.0", BumpMinor, "v1.0.0 -> v1.1.0"}, + {"v1.1.1", BumpPatch, "v1.1.0 -> v1.1.1"}, + {"v2.0.0", BumpMajor, "v1.1.1 -> v2.0.0"}, + {"v2.1.0", BumpMinor, "v2.0.0 -> v2.1.0, stepping over the nightly tag"}, + {"v2.1.1-rc.1", BumpPrerelease, "a prerelease tag classifies itself"}, + {"v2.1.1", BumpPatch, "v2.1.0 -> v2.1.1, stepping over its own rc"}, + } + + for _, tc := range cases { + t.Run(tc.tag, func(t *testing.T) { + got, err := ComputeSemverBump(repo, tc.tag) + if err != nil { + t.Fatalf("ComputeSemverBump(%q) = error %v; want %q (%s)", tc.tag, err, tc.want, tc.why) + } + if got != tc.want { + t.Errorf("ComputeSemverBump(%q) = %q, want %q (%s)", tc.tag, got, tc.want, tc.why) + } + if !got.Valid() { + t.Errorf("ComputeSemverBump(%q) returned %q, which is not in BumpKindValues() = %v", + tc.tag, got, BumpKindValues()) + } + }) + } +} + +// The first tag in a repository has nothing before it. That must be +// ErrNoPreviousTag and must NOT be ErrShallowCheckout: this repository's +// history is complete, and reporting a misconfigured checkout here would make +// the real sentinel meaningless. +func TestComputeSemverBump_FirstTagHasNoPrevious(t *testing.T) { + repo := newFixtureRepo(t) + + _, err := ComputeSemverBump(repo, "v1.0.0") + if !errors.Is(err, ErrNoPreviousTag) { + t.Fatalf("ComputeSemverBump(v1.0.0) = %v; want ErrNoPreviousTag", err) + } + if errors.Is(err, ErrShallowCheckout) { + t.Errorf("a complete repository's first tag reported ErrShallowCheckout: %v", err) + } +} + +// --------------------------------------------------------------------------- +// The required scenario: a shallow checkout, reported specifically +// --------------------------------------------------------------------------- + +func TestComputeSemverBump_ShallowCheckout(t *testing.T) { + repo := newFixtureRepo(t) + + for _, depth := range []int{1, 3} { + t.Run(fmt.Sprintf("depth-%d", depth), func(t *testing.T) { + clone := newShallowClone(t, repo, "v2.1.1", depth) + + // Prove the fixture really is shallow before asserting on it: a + // clone that quietly ignored --depth would make this test pass + // while testing nothing. + env := fixtureEnv(t) + if got := git(t, env, clone, "rev-parse", "--is-shallow-repository"); got != "true" { + t.Fatalf("the fixture clone is not shallow (rev-parse said %q); "+ + "--depth was ignored, so this test would prove nothing", got) + } + + // Document whether `git describe` would have answered anyway. At + // depth 3 it typically does, which is exactly the hole in the + // post-hoc heuristic: an implementation that only checked + // .git/shallow AFTER a describe failure would return a bump here. + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", "v2.1.1^") + cmd.Dir = clone + cmd.Env = env + if out, err := cmd.Output(); err == nil { + t.Logf("at depth %d `git describe` still answers %q; "+ + "the up-front shallow check is what rejects it", + depth, strings.TrimSpace(string(out))) + } else { + t.Logf("at depth %d `git describe` fails outright: %v", depth, err) + } + + got, err := ComputeSemverBump(clone, "v2.1.1") + if !errors.Is(err, ErrShallowCheckout) { + t.Fatalf("ComputeSemverBump on a depth-%d clone = (%q, %v); want ErrShallowCheckout", + depth, got, err) + } + if errors.Is(err, ErrNoPreviousTag) { + t.Errorf("a shallow checkout was reported as ErrNoPreviousTag, "+ + "which is the confusion the two sentinels exist to prevent: %v", err) + } + if got != BumpNone { + t.Errorf("a rejected computation returned the bump %q; want BumpNone", got) + } + + // The message must name the fix. This is the whole point of the + // sentinel: an operator reading a CI log has to learn what to + // change without reading Anvil's source. + for _, want := range []string{"fetch-depth: 0", "fetch-tags: true"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("ErrShallowCheckout message does not mention %q; got: %v", want, err) + } + } + }) + } +} + +// A shallow checkout is rejected even for a prerelease tag, whose classification +// needs no history at all. The contract is uniform on purpose: "on a shallow +// checkout, ComputeSemverBump never returns a bump" is a rule an operator can +// hold in their head, and a per-tag-kind exception is not. +func TestComputeSemverBump_ShallowRejectedEvenWhenHistoryIsNotNeeded(t *testing.T) { + repo := newFixtureRepo(t) + clone := newShallowClone(t, repo, "v2.1.1-rc.1", 1) + + if _, err := ComputeSemverBump(clone, "v2.1.1-rc.1"); !errors.Is(err, ErrShallowCheckout) { + t.Fatalf("prerelease tag on a shallow clone = %v; want ErrShallowCheckout", err) + } +} + +// --------------------------------------------------------------------------- +// The other error paths, each distinguishable from the others +// --------------------------------------------------------------------------- + +func TestComputeSemverBump_ErrorPaths(t *testing.T) { + repo := newFixtureRepo(t) + notARepo := t.TempDir() + + cases := []struct { + name string + path string + tag string + want error + }{ + {"not a semantic version", repo, "nightly-2026-08-09", ErrNotSemver}, + {"two-component tag", repo, "v2.1", ErrNotSemver}, + {"leading zero", repo, "v01.2.3", ErrNotSemver}, + {"empty tag", repo, "", ErrNotSemver}, + {"unknown tag", repo, "v9.9.9", ErrTagNotFound}, + {"not a repository", notARepo, "v1.0.0", ErrGit}, + {"no repository path", "", "v1.0.0", ErrGit}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ComputeSemverBump(tc.path, tc.tag) + if !errors.Is(err, tc.want) { + t.Fatalf("ComputeSemverBump(%q, %q) = (%q, %v); want %v", tc.path, tc.tag, got, err, tc.want) + } + if got != BumpNone { + t.Errorf("a failed computation returned the bump %q; want BumpNone", got) + } + // "git error" with no detail is the failure mode the packet + // forbids; every error here must say something specific. + if len(err.Error()) < len(tc.want.Error())+8 { + t.Errorf("error is not specific enough to act on: %v", err) + } + }) + } +} + +// A tag that exists but is not a semantic version must fail on the TAG, not on +// the repository: ErrNotSemver is raised before git is consulted at all, so the +// same message appears whether or not the tag was ever pushed. +func TestComputeSemverBump_NonSemverTagFailsBeforeGit(t *testing.T) { + _, err := ComputeSemverBump(filepath.Join(t.TempDir(), "does-not-exist"), "nightly-2026-08-09") + if !errors.Is(err, ErrNotSemver) { + t.Fatalf("got %v; want ErrNotSemver regardless of the repository", err) + } +} + +// --------------------------------------------------------------------------- +// parseSemver +// --------------------------------------------------------------------------- + +func TestParseSemver(t *testing.T) { + ok := []struct { + tag string + want semver + }{ + {"v1.2.3", semver{major: 1, minor: 2, patch: 3}}, + {"1.2.3", semver{major: 1, minor: 2, patch: 3}}, + {"v0.0.0", semver{}}, + {"v10.20.30", semver{major: 10, minor: 20, patch: 30}}, + {"v1.2.3-rc.1", semver{major: 1, minor: 2, patch: 3, prerelease: "rc.1"}}, + {"v1.2.3-0.build-7", semver{major: 1, minor: 2, patch: 3, prerelease: "0.build-7"}}, + {"v1.2.3+meta", semver{major: 1, minor: 2, patch: 3, build: "meta"}}, + {"v1.2.3-rc.1+meta.001", semver{major: 1, minor: 2, patch: 3, prerelease: "rc.1", build: "meta.001"}}, + {"v18446744073709551615.0.0", semver{major: 18446744073709551615}}, + } + for _, tc := range ok { + t.Run(tc.tag, func(t *testing.T) { + got, err := parseSemver(tc.tag) + if err != nil { + t.Fatalf("parseSemver(%q) = %v", tc.tag, err) + } + if got != tc.want { + t.Errorf("parseSemver(%q) = %+v, want %+v", tc.tag, got, tc.want) + } + }) + } + + bad := []string{ + "", + "v", + "v1", + "v1.2", + "v1.2.3.4", + "V1.2.3", // uppercase prefix is a second spelling; rejected + "release-1.2.3", // not a version tag at all + "v01.2.3", // leading zero (semver 2.0.0 section 2) + "v1.02.3", // + "v1.2.03", // + "v1.2.-3", // + "v1.2.3-", // empty prerelease + "v1.2.3+", // empty build metadata + "v1.2.3-rc..1", // empty identifier + "v1.2.3-rc.01", // numeric prerelease identifier with a leading zero + "v1.2.3-rc$1", // illegal character + "v1.2.3+meta$1", // + "v1.2.x", // + "v 1.2.3", // + "v18446744073709551616.0.0", // does not fit in uint64 + } + for _, tag := range bad { + t.Run("reject "+strconv.Quote(tag), func(t *testing.T) { + got, err := parseSemver(tag) + if err == nil { + t.Fatalf("parseSemver(%q) = %+v, want an error", tag, got) + } + if !errors.Is(err, ErrNotSemver) { + t.Errorf("parseSemver(%q) = %v; want ErrNotSemver", tag, err) + } + if !strings.Contains(err.Error(), strconv.Quote(tag)) { + t.Errorf("the error does not quote the offending tag: %v", err) + } + }) + } +} + +// Build metadata carries no precedence (semver 2.0.0 section 10), so it must +// not reach the classification. Two tags differing only in build metadata have +// the same core and the walk steps over them. +func TestClassifyCore(t *testing.T) { + v := func(tag string) semver { + t.Helper() + s, err := parseSemver(tag) + if err != nil { + t.Fatalf("fixture %q: %v", tag, err) + } + return s + } + + cases := []struct { + prev, next string + want BumpKind + }{ + {"v1.0.0", "v2.0.0", BumpMajor}, + {"v1.9.9", "v2.0.0", BumpMajor}, + {"v2.0.0", "v1.0.0", BumpMajor}, // most significant CHANGED component; no ordering claim + {"v1.0.0", "v1.1.0", BumpMinor}, + {"v1.0.9", "v1.1.0", BumpMinor}, + {"v1.1.0", "v1.1.1", BumpPatch}, + {"v1.1.1", "v1.1.1", BumpNone}, + {"v1.1.1+a", "v1.1.1+b", BumpNone}, + {"v1.1.1-rc.1", "v1.1.1", BumpNone}, // same core: keep walking + } + for _, tc := range cases { + t.Run(tc.prev+"->"+tc.next, func(t *testing.T) { + if got := classifyCore(v(tc.prev), v(tc.next)); got != tc.want { + t.Errorf("classifyCore(%s, %s) = %q, want %q", tc.prev, tc.next, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// The enum this file consumes and must never fork +// --------------------------------------------------------------------------- + +// TestSemverFileDeclaresNoSecondBumpVocabulary mirrors +// TestFrozenEnumsAreNotForked for O.7's half of the package. +// +// schemas/policy.schema.json owns the semverBump enum and engine.go is its one +// Go image. A bump literal in semver.go's CODE would be a second definition of +// it -- the defect class plan/IMPLEMENTATION-PLAN.md section 6 closed ten +// instances of. Prose may name the tokens, so this walks the AST rather than +// grepping. +func TestSemverFileDeclaresNoSecondBumpVocabulary(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "semver.go", nil, 0) + if err != nil { + t.Fatalf("parsing semver.go: %v", err) + } + + banned := map[string]bool{} + for _, kind := range BumpKindValues() { + banned[string(kind)] = true + } + + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + if banned[val] { + t.Errorf("semver.go line %d contains the bump literal %q; bump tokens are the "+ + "BumpKind constants in engine.go, never a second list here", + fset.Position(lit.Pos()).Line, val) + } + return true + }) +} + +// Whatever ComputeSemverBump returns on success is a token the schema declares. +// Asserted over the whole fixture rather than case by case so a future bump +// kind cannot be introduced here without the schema learning about it. +func TestComputeSemverBump_ReturnsOnlySchemaTokens(t *testing.T) { + repo := newFixtureRepo(t) + + for _, step := range fixtureHistory { + got, err := ComputeSemverBump(repo, step.tag) + if err != nil { + continue // the root tag and the nightly tag have no bump, by design + } + if !slices.Contains(BumpKindValues(), got) { + t.Errorf("ComputeSemverBump(%q) = %q, which is not one of %v", + step.tag, got, BumpKindValues()) + } + } +} + +// The computed bump is what a policy rule matches against, so the two halves of +// this package have to agree end to end: a rule gated on major must fire for the +// tag ComputeSemverBump calls a major bump, and must not fire for the others. +func TestComputeSemverBump_FeedsTheEngine(t *testing.T) { + repo := newFixtureRepo(t) + + p := Policy{ + Version: SchemaVersion, + ScanRules: []ScanRule{{ + Name: "major-release-full", + MatchRefs: []string{"refs/tags/v*"}, + MatchSemverBump: []BumpKind{BumpMajor}, + Settings: Settings{Depth: DepthFull}, + }}, + } + + fired := map[string]bool{} + for _, step := range fixtureHistory { + bump, err := ComputeSemverBump(repo, step.tag) + if err != nil && !errors.Is(err, ErrNoPreviousTag) && !errors.Is(err, ErrNotSemver) { + t.Fatalf("ComputeSemverBump(%q): %v", step.tag, err) + } + res, err := Evaluate(p, TriggerContext{ + Event: "push", + Ref: "refs/tags/" + step.tag, + SemverBump: bump, + }) + if err != nil { + t.Fatalf("Evaluate(%q): %v", step.tag, err) + } + fired[step.tag] = len(res.Matched) == 1 + } + + for _, step := range fixtureHistory { + want := step.tag == "v2.0.0" + if fired[step.tag] != want { + t.Errorf("the major-gated rule fired=%v for %q; want %v", fired[step.tag], step.tag, want) + } + } +} diff --git a/internal/record/critique03_regression_test.go b/internal/record/critique03_regression_test.go index 079b419..bfef58b 100644 --- a/internal/record/critique03_regression_test.go +++ b/internal/record/critique03_regression_test.go @@ -153,11 +153,31 @@ func TestXVM1ExpiredAuditIsRefusedEverywhere(t *testing.T) { } } +// xvOneRunSeal returns a REAL half seal for a one-run record in the given +// (state, status), by going through halfSealOfRun — one of the two producers +// the gate accepts provenance from. A test that wants a seal must obtain one +// the way production code does; building the struct is the defect, not the +// setup. +func xvOneRunSeal(state State, status HalfStatus) HalfSeal { + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "xv-audit", State: state}, + Runs: []Run{{Properties: RunProperties{Half: HalfSast, Status: status}}}, + } + return halfSealOfRun(l, &l.Runs[0]) +} + // TestXVM1ExpiredIsDistinctFromUnsealed asserts the refusal STRINGS differ, so // "the window closed" and "never sealed" do not arrive as one observation. func TestXVM1ExpiredIsDistinctFromUnsealed(t *testing.T) { - expired := halfReadRefusal(HalfSeal{Half: HalfSast, Status: HalfStatusSealed, AuditState: StateExpired}) - unsealed := halfReadRefusal(HalfSeal{Half: HalfSast, Status: HalfStatusRunning, AuditState: StateCollecting}) + // The two seals are OBTAINED FROM A PRODUCER, not hand-built. This test + // used to write `HalfSeal{Half: ..., Status: ..., AuditState: ...}` + // literals, which is adversary attack 14's shape — a seal the gate was + // asked to answer about that no producer minted — and since seal + // provenance landed the gate refuses those before it reaches either arm, + // so the literals would have compared two provenance refusals to each + // other and asserted nothing about expiry or sealing at all. + expired := halfReadRefusal(xvOneRunSeal(StateExpired, HalfStatusSealed)).reason + unsealed := halfReadRefusal(xvOneRunSeal(StateCollecting, HalfStatusRunning)).reason t.Logf("expired refusal = %q", expired) t.Logf("unsealed refusal = %q", unsealed) if expired == "" || unsealed == "" { diff --git a/internal/record/provenance_test.go b/internal/record/provenance_test.go new file mode 100644 index 0000000..d5f11c7 --- /dev/null +++ b/internal/record/provenance_test.go @@ -0,0 +1,1454 @@ +// Seal provenance — the runtime closure of adversary attack 14, and the tests +// that are the difference between closing it and saying it was closed. +// +// readpath_test.go's KNOWN LIMITS carried attack 14 as an OPEN hole for two +// rounds: call the read gate, check the error, obey it, and hand it a HalfSeal +// you built yourself. The static guard cannot see it, because which value +// flowed into which parameter is a dataflow question. The section proposed the +// fix that landed: +// +// "an unexported provenance field on HalfSeal that only halfSealOfRun and +// the Sealer can set, with HalfReadGate refusing any seal without it" +// +// It was not a hypothetical. CRITIQUE O.4 found the shape occurring NATURALLY +// in internal/scanctl: AuditRecord.HalfSeal assembled a record.HalfSeal from +// caller-held fields, with no refresh path, and handed it to the gate. Nobody +// was attacking anything; it is simply the natural way to write it. +// +// TestGateRefusesAHandBuiltHalfSeal below rebuilds that exact shape and asserts +// the gate now refuses it. Every other test in this file covers one of the +// remaining provenance faults, and each has a POSITIVE CONTROL alongside it: a +// refusal that fires for every input is not a gate, it is an outage. + +package record + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// ATTACK 14 — the fabricated seal +// --------------------------------------------------------------------------- + +// provFabricatedSeal builds a HalfSeal the way a caller OUTSIDE this package +// would: exported fields only, assembled from state the caller happens to +// hold. It is character for character the shape internal/scanctl's +// AuditRecord.HalfSeal produced — +// +// record.HalfSeal{ +// Half: h.Half, +// Status: h.Status, +// SealedAt: copyTime(h.SealedAt), +// AuditState: r.State, +// } +// +// — and it deliberately does NOT set prov, because no package outside +// internal/record can: an unexported field in a composite literal is a compile +// error, and that compile error is the whole mechanism. +func provFabricatedSeal(half Half, status HalfStatus, state State, sealedAt *time.Time) HalfSeal { + return HalfSeal{ + Half: half, + Status: status, + SealedAt: copyTime(sealedAt), + AuditState: state, + } +} + +// TestGateRefusesAHandBuiltHalfSeal is the deliverable: the gate must refuse a +// seal no producer minted, EVEN WHEN every exported field on it says the half +// is cleanly sealed and the audit is live. +// +// The facts on this seal are not lies. They are exactly the facts a real seal +// would carry for a readable SAST half — the positive control below proves it +// by obtaining a real seal with the same facts and reading through it. The +// refusal is about the seal's ORIGIN, and about nothing else. +func TestGateRefusesAHandBuiltHalfSeal(t *testing.T) { + sealedAt := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + fabricated := provFabricatedSeal(HalfSast, HalfStatusSealed, StateBothSealed, &sealedAt) + + err := HalfReadGate("audit-fabricated", fabricated) + if err == nil { + t.Fatal("ATTACK 14 REPRODUCED: HalfReadGate accepted a HalfSeal that no producer " + + "minted. A caller can build a seal that says `sealed` and read any half it likes.") + } + if fabricated.Readable() { + t.Error("ATTACK 14 REPRODUCED via the bool spelling: Readable() is true on a fabricated " + + "seal. HalfReadGate and Readable() must never disagree.") + } + + // The refusal is a read-gate refusal, so every caller written before + // provenance existed still branches correctly... + if !errors.Is(err, ErrHalfNotSealed) { + t.Errorf("refusal does not match ErrHalfNotSealed: %v", err) + } + var rge *ReadGateError + if !errors.As(err, &rge) { + t.Fatalf("refusal is %T, want a *ReadGateError", err) + } + // ...and it is DISTINCT, so a caller that wants to know it was refused for + // provenance rather than for an unsealed half can find out. + if !errors.Is(err, ErrSealNotFromProducer) { + t.Errorf("refusal does not match ErrSealNotFromProducer: %v", err) + } + if errors.Is(err, ErrSealStale) { + t.Error("a seal nobody minted reported as STALE; absent provenance and stale " + + "provenance are different faults about different objects") + } + var pe *SealProvenanceError + if !errors.As(err, &pe) { + t.Fatalf("refusal carries no *SealProvenanceError (Cause = %v)", rge.Cause) + } + if pe.Fault != SealProvenanceAbsent { + t.Errorf("fault = %q, want %q", pe.Fault, SealProvenanceAbsent) + } + if pe.AuditID != "audit-fabricated" || pe.Half != HalfSast { + t.Errorf("the provenance refusal names audit %q half %q; it must name the caller's", + pe.AuditID, pe.Half) + } + + // It must NAME THE TWO PRODUCERS. A caller that trips this is by + // construction a caller that does not know a seal has an origin, so + // "refused" without "here is where a real one comes from" sends it to + // guess. + for _, want := range []string{"halfSealOfRun", "Sealer"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q as a producer:\n%v", want, err) + } + if !strings.Contains(pe.Error(), want) { + t.Errorf("the provenance error does not name %q as a producer:\n%v", want, pe) + } + } + + // POSITIVE CONTROL. The same facts, obtained from a producer, are + // readable. Without this the test above would pass just as well against a + // gate that had been broken shut. + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "audit-fabricated", State: StateBothSealed}, + Runs: []Run{{Properties: RunProperties{ + Half: HalfSast, Status: HalfStatusSealed, SealedAt: &sealedAt, + }}}, + } + real := halfSealOfRun(l, &l.Runs[0]) + if err := HalfReadGate("audit-fabricated", real); err != nil { + t.Fatalf("POSITIVE CONTROL FAILED: a producer-minted seal with the same facts was "+ + "refused: %v. The gate is shut, not gated.", err) + } + if factsOfSeal(real) != factsOfSeal(fabricated) { + t.Fatalf("the control seal does not carry the same facts as the fabricated one "+ + "(%v vs %v); the comparison above proves nothing", + factsOfSeal(real), factsOfSeal(fabricated)) + } +} + +// TestGateRefusesTheZeroHalfSeal covers the other end of the same rule: the +// zero value carries no provenance either, and every refusing path in this +// package returns exactly that value. +func TestGateRefusesTheZeroHalfSeal(t *testing.T) { + if (HalfSeal{}).Readable() { + t.Error("the zero HalfSeal is readable") + } + if err := HalfReadGate("", HalfSeal{}); !errors.Is(err, ErrSealNotFromProducer) { + t.Errorf("the zero HalfSeal was refused as %v, want ErrSealNotFromProducer", err) + } + // And a seal a JSON round-trip produced is a fabricated seal: unexported + // fields do not survive one. Modelled here as a copy of the exported + // fields, which is what any decoder produces. + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "a", State: StateBothSealed}, + Runs: []Run{{Properties: RunProperties{Half: HalfDast, Status: HalfStatusSealed}}}, + } + real := halfSealOfRun(l, &l.Runs[0]) + decoded := HalfSeal{Half: real.Half, Status: real.Status, SealedAt: real.SealedAt, AuditState: real.AuditState} + if decoded.Readable() { + t.Error("a seal reassembled from exported fields is readable; provenance does not " + + "survive serialisation and must not be re-acquirable by copying the fields out") + } +} + +// --------------------------------------------------------------------------- +// STALENESS — the fault that actually occurred +// --------------------------------------------------------------------------- + +// TestGateRefusesASealHeldAcrossAStateChange is CRITIQUE O.4's defect in this +// package's own shape: a seal a legitimate producer minted, kept while the +// audit moved, and then used to read. +// +// It runs the Sealer arm (an in-flight audit) and asserts the refusal is +// STALE, not absent — the seal is genuine, and reporting it as forged would +// send an operator to look for a caller that does not exist. +func TestGateRefusesASealHeldAcrossAStateChange(t *testing.T) { + now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + s := NewSealer() + s.SetClock(func() time.Time { return now }) + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "held", StartedAt: now, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + if err := s.SealHalf("held", HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("SealHalf: %v", err) + } + + held, err := s.ReadHalf("held", HalfSast) + if err != nil { + t.Fatalf("ReadHalf: %v", err) + } + if !held.Readable() { + t.Fatal("the seal ReadHalf just handed out is not readable; the gate is shut") + } + + // The audit moves on. The consumer still holds the seal it got ten + // minutes ago and never refreshed. + if err := s.Consume("held"); err != nil { + t.Fatalf("Consume: %v", err) + } + + if err := HalfReadGate("held", held); err == nil { + t.Fatal("O.4 REPRODUCED: a seal minted before a state change still opens the gate. " + + "The gate answered truthfully about a snapshot nobody refreshed.") + } else { + var pe *SealProvenanceError + if !errors.As(err, &pe) { + t.Fatalf("the refusal is %v, with no provenance detail", err) + } + if pe.Fault != SealProvenanceStale { + t.Errorf("fault = %q, want %q; the seal is genuine and only its age is wrong", + pe.Fault, SealProvenanceStale) + } + if !errors.Is(err, ErrSealStale) || !errors.Is(err, ErrHalfNotSealed) { + t.Errorf("a stale refusal must match both ErrSealStale and ErrHalfNotSealed: %v", err) + } + if pe.LiveVersion <= pe.Version { + t.Errorf("the refusal reports minted version %d and live version %d; the live "+ + "version must have advanced or the staleness check has no substrate", + pe.Version, pe.LiveVersion) + } + } + + // POSITIVE CONTROL, and S1's re-entrancy: a FRESHLY obtained seal for the + // same consumed audit is still readable. Staleness must not be a one-way + // door that consumption closes. + fresh, err := s.ReadHalf("held", HalfSast) + if err != nil { + t.Fatalf("POSITIVE CONTROL FAILED: ReadHalf refused a consumed audit: %v "+ + "(S1 requires a RE-ENTRANT consumer)", err) + } + if !fresh.Readable() { + t.Error("POSITIVE CONTROL FAILED: a freshly minted seal is not readable") + } +} + +// TestReMintingAnUnchangedSealStaysCurrent is the anti-overshoot control for +// the test above. If any Sealer call bumped the version, every seal would be +// stale by the time it was used and the gate would be an outage. +func TestReMintingAnUnchangedSealStaysCurrent(t *testing.T) { + now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + s := NewSealer() + s.SetClock(func() time.Time { return now }) + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "steady", StartedAt: now, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + if err := s.SealHalf("steady", HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("SealHalf: %v", err) + } + first, err := s.ReadHalf("steady", HalfSast) + if err != nil { + t.Fatalf("ReadHalf: %v", err) + } + + // Reads, snapshots, a re-seal with the identical status (documented as a + // no-op), and a not-yet-due expiry check must all leave the seal current. + if _, ok := s.Inspect("steady"); !ok { + t.Fatal("Inspect: audit missing") + } + if sast, _ := s.ReadyForConsumption("steady"); !sast { + t.Fatal("ReadyForConsumption says the sealed SAST half is not ready") + } + if err := s.SealHalf("steady", HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("idempotent re-seal: %v", err) + } + if expired, err := s.ExpireIfDue("steady"); err != nil || expired { + t.Fatalf("ExpireIfDue before the deadline: (%v, %v)", expired, err) + } + if err := HalfReadGate("steady", first); err != nil { + t.Errorf("a seal went stale although nothing about the audit changed: %v.\n"+ + " A staleness check that fires on no-ops is an outage, not a gate.", err) + } +} + +// TestGateRefusesASealWhoseRecordMovedOn is the record-side arm of the same +// rule: halfSealOfRun's provenance holds the live (*SARIFLog, *Run), and the +// gate re-reads them. +// +// It runs the mutation in the direction that OPENS the gate — a running half +// that later seals — so the refusal cannot be mistaken for the status arm +// doing the work. +func TestGateRefusesASealWhoseRecordMovedOn(t *testing.T) { + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "moved", State: StateCollecting}, + Runs: []Run{{Properties: RunProperties{Half: HalfSast, Status: HalfStatusRunning}}}, + } + held := halfSealOfRun(l, &l.Runs[0]) + if held.Readable() { + t.Fatal("a running half is readable; the status arm is gone") + } + + // The record seals. The held seal still describes the running half. + sealedAt := time.Date(2026, 8, 9, 10, 0, 0, 0, time.UTC) + l.Properties.State = StateBothSealed + l.Runs[0].Properties.Status = HalfStatusSealed + l.Runs[0].Properties.SealedAt = &sealedAt + + err := HalfReadGate("moved", held) + if err == nil { + t.Fatal("the gate accepted a seal that no longer matches the record it came from") + } + var pe *SealProvenanceError + if !errors.As(err, &pe) || pe.Fault != SealProvenanceStale { + t.Fatalf("refusal = %v, want a stale-provenance refusal", err) + } + if !strings.Contains(pe.Minted, string(HalfStatusRunning)) || + !strings.Contains(pe.Live, string(HalfStatusSealed)) { + t.Errorf("the refusal reports minted=%q live=%q; it must say what changed", + pe.Minted, pe.Live) + } + + // POSITIVE CONTROL: re-projecting the SAME record now opens the gate. + if err := HalfReadGate("moved", halfSealOfRun(l, &l.Runs[0])); err != nil { + t.Fatalf("POSITIVE CONTROL FAILED: a freshly projected seal over a sealed half was "+ + "refused: %v", err) + } +} + +// TestGateRefusesAnEditedSeal closes the near-miss of attack 14: obtain a +// genuine seal, then assign to its exported fields. Copying a HalfSeal is +// legal and happens everywhere; EDITING one turns a real seal into a +// fabricated one that still carries a producer's mark. +func TestGateRefusesAnEditedSeal(t *testing.T) { + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "edited", State: StateCollecting}, + Runs: []Run{{Properties: RunProperties{Half: HalfDast, Status: HalfStatusRunning}}}, + } + seal := halfSealOfRun(l, &l.Runs[0]) + + edited := seal // a copy carries the provenance, as any pass-by-value does + edited.Status = HalfStatusSealed + edited.AuditState = StateBothSealed + + err := HalfReadGate("edited", edited) + if err == nil { + t.Fatal("ATTACK 14 VARIANT REPRODUCED: a real seal was edited to say `sealed` and " + + "the gate honoured it. Provenance that survives editing is a rubber stamp.") + } + var pe *SealProvenanceError + if !errors.As(err, &pe) || pe.Fault != SealProvenanceTampered { + t.Fatalf("refusal = %v, want a tampered-provenance refusal", err) + } + + // The half field is part of the facts too, which is what makes "obey the + // gate for SAST, relabel the seal, read DAST" not work. It is NOT a fix + // for attack 15 — see readpath_test.go's KNOWN LIMITS — because attack 15 + // never needs to relabel anything. + relabelled := seal + relabelled.Half = HalfSast + if relabelled.Readable() { + t.Error("a seal relabelled onto the other half is readable") + } + + // POSITIVE CONTROL: the untouched original still answers for itself. + if seal.Readable() { + t.Error("the original seal is readable; it describes a RUNNING half") + } + if err := HalfReadGate("edited", seal); !errors.Is(err, ErrHalfNotSealed) || + errors.Is(err, ErrSealStale) || errors.Is(err, ErrSealNotFromProducer) { + t.Errorf("the untouched seal was refused as %v; it must be refused by the STATUS arm, "+ + "not by provenance", err) + } +} + +// TestGateRefusesASealFromAForgottenAudit: once Sealer.Forget drops an audit, +// seals minted from it can no longer be checked against anything, and an +// unverifiable seal is refused like any other. +func TestGateRefusesASealFromAForgottenAudit(t *testing.T) { + now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + s := NewSealer() + s.SetClock(func() time.Time { return now }) + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "gone", StartedAt: now, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + if err := s.SealHalf("gone", HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("SealHalf: %v", err) + } + seal, err := s.ReadHalf("gone", HalfSast) + if err != nil { + t.Fatalf("ReadHalf: %v", err) + } + if !seal.Readable() { + t.Fatal("the seal ReadHalf handed out is not readable") + } + + s.Forget("gone") + + if seal.Readable() { + t.Error("a seal minted from a forgotten audit is still readable; there is nothing " + + "left to check it against and it must not be believed") + } + var pe *SealProvenanceError + if err := HalfReadGate("gone", seal); !errors.As(err, &pe) { + t.Fatalf("refusal = %v, want a provenance refusal", err) + } else if pe.Fault != SealProvenanceOriginGone { + t.Errorf("fault = %q, want %q", pe.Fault, SealProvenanceOriginGone) + } +} + +// --------------------------------------------------------------------------- +// THE PRODUCER CENSUS — there are two, and a third must not appear quietly +// --------------------------------------------------------------------------- + +// TestOnlyTwoProducersStampProvenance reads the package as data and fails if +// any function other than halfSealOfRun and audit.halfSeal writes the +// provenance field. +// +// The compiler stops OTHER PACKAGES from forging provenance; nothing stops +// this one. A third producer added here — an exported constructor, a +// "convenience" helper for a caller that finds the gate inconvenient — would +// reopen attack 14 with the package's own help, and would look entirely +// reasonable in review. This test is what makes that visible. +func TestOnlyTwoProducersStampProvenance(t *testing.T) { + const provField = "prov" + legitimate := map[string]bool{ + "halfSealOfRun": true, + "audit.halfSeal": true, + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parsing the package source: %v", err) + } + if len(pkgs) == 0 { + t.Fatal("parsed no packages; this test asserts nothing unless it reads the source") + } + + found := map[string]bool{} + for _, pkg := range pkgs { + for path, file := range pkg.Files { + base := filepath.Base(path) + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + name := fn.Name.Name + if fn.Recv != nil && len(fn.Recv.List) == 1 { + if recv := gateBaseTypeName(fn.Recv.List[0].Type); recv != "" { + name = recv + "." + name + } + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + var writes bool + switch v := n.(type) { + case *ast.KeyValueExpr: + // HalfSeal{..., prov: ...} + if id, ok := v.Key.(*ast.Ident); ok && id.Name == provField { + writes = true + } + case *ast.AssignStmt: + // seal.prov = ... + for _, lhs := range v.Lhs { + if sel, ok := lhs.(*ast.SelectorExpr); ok && sel.Sel.Name == provField { + writes = true + } + } + } + if !writes { + return true + } + found[name] = true + if !legitimate[name] { + t.Errorf("%s (%s) stamps seal provenance, and it is not one of the two "+ + "legitimate producers.\n"+ + " THE UNEXPORTED FIELD IS THE WHOLE MECHANISM: the compiler stops\n"+ + " other packages from forging it, and this list is what stops this\n"+ + " one. A third producer is adversary attack 14 reopened from inside.\n"+ + " If this really is a producer, say why here and add it to the list;\n"+ + " if it is a convenience for a caller that found the gate awkward,\n"+ + " the caller is the thing to fix.", name, base) + } + return true + }) + } + } + } + + for want := range legitimate { + if !found[want] { + t.Errorf("%s no longer stamps provenance. It is one of the two producers the gate's "+ + "refusal message names; if it has stopped minting, either the mechanism is gone "+ + "or the message is now a lie.", want) + } + } +} + +// =========================================================================== +// THE MUTATION FUNNEL — publish-on-every-mutation, and the proof it matters +// =========================================================================== +// +// Everything above tests the gate's REACTION to a stale seal. This section +// tests the thing that makes a seal stale in the first place: the audit +// publishes an atomic facts snapshot on every mutation, and a held seal is +// compared against it. +// +// The re-verification of R.6 found two mutations that could skip that publish — +// Sealer.SealHalf's DAST branch and Sealer.SealDastIfDeadlineDue, both of which +// assigned the half's fields and then returned a derivation error from between +// the assignment and the publish. It classified them LATENT, NOT REACHABLE +// TODAY, and it was right about reachability: DeriveDastStatus cannot fail on +// inputs the Sealer's own validators admit. +// +// It matters anyway, and TestASealHeldAcrossAnUnpublishedMutationReadsAsCurrent +// below is the demonstration rather than the assertion. An unpublished mutation +// does not weaken the staleness check; it INVERTS it. The gate reads its two +// arms off the seal's own fields, and the staleness check is the entire reason +// those fields may be believed. Take the publish away and a seal minted before +// an EXPIRY still says `sealed` / `both_sealed`, still matches the published +// facts, and the gate hands a consumer the results of an audit whose payload the +// reaper has already dropped. That is CRITIQUE-03 M1's harm arriving through the +// mechanism built to prevent it. + +// provAudit reaches into the Sealer for the live *audit. Every test in this +// section needs it: the defect is a disagreement between an audit's FIELDS and +// its PUBLISHED facts, and nothing exported can show both. +func provAudit(t *testing.T, s *Sealer, auditID string) *audit { + t.Helper() + a, ok := s.audits[auditID] + if !ok { + t.Fatalf("audit %q is not registered with this Sealer", auditID) + } + return a +} + +// provFactsDisagreement returns a description of how an audit's published facts +// differ from its fields, or "" when they agree. +// +// AGREEMENT IS THE INVARIANT. Everything the staleness gate does rests on it: +// `live` is the substrate a held seal is checked against, so the moment it lags +// the fields, a seal describing the OLD record compares equal to the CURRENT +// one. +func provFactsDisagreement(a *audit) string { + live := a.live.Load() + if live == nil { + return "nothing has ever been published for this audit" + } + if want := a.factsFor(HalfSast); live.sast != want { + return fmt.Sprintf("SAST half: published %v, but the fields say %v", live.sast, want) + } + if want := a.factsFor(HalfDast); live.dast != want { + return fmt.Sprintf("DAST half: published %v, but the fields say %v", live.dast, want) + } + return "" +} + +// provCorruptDastProvenance makes DeriveDastStatus FAIL for this audit. +// +// It is the only way to reach the two paths at all, and it is why they were +// classified latent: RecordDastOutcome validates the provenance token and +// BeginAudit supplies a legal default, so no caller outside this package can +// put an illegal one in place. This test file is inside the package and can, so +// the paths the re-verifier could only read are paths this file can EXECUTE. +func provCorruptDastProvenance(t *testing.T, s *Sealer, auditID string) *audit { + t.Helper() + a := provAudit(t, s, auditID) + a.dastOutcome.TierInstalled = true + a.dastOutcome.Provenance = TargetProvenance("gremlin-not-a-provenance") + if _, err := DeriveDastStatus(HalfStatusSealed, a.dastOutcome); err == nil { + t.Fatal("the probe did not actually break the derivation, so every assertion below " + + "would pass against the unfixed code as well; it proves nothing") + } + return a +} + +// TestASealHeldAcrossAnUnpublishedMutationReadsAsCurrent is the proof the +// re-verification asked for: construct the sequence that would have left a +// stale seal reading CURRENT, and assert the gate now refuses it. +// +// Two identical audits, one transition, two ways of making it: +// +// A "unpublished" — the audit's state field is assigned directly, the way an +// early return between an assignment and a publish leaves it. The gate is +// FOOLED: a seal minted before the transition still matches the published +// facts, so the staleness arm passes, and the gate then reads its two +// readability arms off that seal's own stale fields and OPENS on an EXPIRED +// audit. +// B "funnelled" — the same transition through Sealer.ExpireIfDue, which goes +// through audit.setLifecycleState, which publishes. The identical held seal +// is refused as SealProvenanceStale. +// +// A is not an assertion that the product is broken. It is the negative control +// that gives B its meaning: without it, B would pass just as well against a gate +// that refused everything. +func TestASealHeldAcrossAnUnpublishedMutationReadsAsCurrent(t *testing.T) { + start := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + + // setup returns a Sealer whose audit has a cleanly sealed, READABLE SAST + // half, plus a seal a consumer obtained and held. + setup := func(id string) (*Sealer, *time.Time, HalfSeal) { + now := start + s := NewSealer() + s.SetClock(func() time.Time { return now }) + if _, err := s.BeginAudit(AuditConfig{ + AuditID: id, StartedAt: start, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + if err := s.SealHalf(id, HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("SealHalf: %v", err) + } + held, err := s.ReadHalf(id, HalfSast) + if err != nil { + t.Fatalf("ReadHalf: %v", err) + } + if !held.Readable() { + t.Fatal("the seal ReadHalf handed out is not readable; the scenario never starts") + } + if held.AuditState != StateBothSealed || held.Status != HalfStatusSealed { + t.Fatalf("the held seal reads %s/%s, want %s/%s", + held.Status, held.AuditState, HalfStatusSealed, StateBothSealed) + } + return s, &now, held + } + + // ---- A: the transition WITHOUT a publish ----------------------------- + sA, _, heldA := setup("unpublished") + aA := provAudit(t, sA, "unpublished") + aA.state = StateExpired // exactly what an early return before publish leaves + + if d := provFactsDisagreement(aA); d == "" { + t.Fatal("the probe did not desynchronise the published facts from the fields, so the " + + "control below demonstrates nothing") + } + if err := HalfReadGate("unpublished", heldA); err != nil { + t.Fatalf("the unpublished-mutation control did not reproduce the inversion: %v.\n"+ + " It is supposed to show the gate being FOOLED. If it no longer can, the\n"+ + " mechanism has changed and the assertion below is measuring something else.", err) + } + if !heldA.Readable() { + t.Fatal("the unpublished-mutation control did not reproduce the inversion via Readable()") + } + // Stated for the record, because this is the whole point: at this instant + // the live audit is EXPIRED — the reaper has dropped its payload — and the + // one gate that stands between a consumer and those results said yes. + + // ---- B: the same transition THROUGH the funnel ------------------------ + sB, nowB, heldB := setup("funnelled") + if factsOfSeal(heldA) != factsOfSeal(heldB) { + t.Fatalf("the two held seals do not carry the same facts (%v vs %v); A and B are not "+ + "the same scenario and comparing their outcomes proves nothing", + factsOfSeal(heldA), factsOfSeal(heldB)) + } + *nowB = start.Add(2 * time.Hour) // past deadline_at + expired, err := sB.ExpireIfDue("funnelled") + if err != nil || !expired { + t.Fatalf("ExpireIfDue = (%v, %v); the scenario requires the audit to expire", expired, err) + } + if d := provFactsDisagreement(provAudit(t, sB, "funnelled")); d != "" { + t.Fatalf("ExpireIfDue left the published facts behind the fields: %s.\n"+ + " Every mutation must publish; that is the invariant the staleness gate rests on.", d) + } + + err = HalfReadGate("funnelled", heldB) + if err == nil { + t.Fatal("RESIDUAL 2 REPRODUCED: a seal minted before an EXPIRY still opens the gate. " + + "The staleness check is not degraded by an unpublished mutation, it is inverted: " + + "the consumer reads a half whose payload the reaper has dropped.") + } + if heldB.Readable() { + t.Error("RESIDUAL 2 REPRODUCED via the bool spelling: Readable() is true on a seal held " + + "across an expiry") + } + var pe *SealProvenanceError + if !errors.As(err, &pe) { + t.Fatalf("the refusal is %v, with no provenance detail", err) + } + if pe.Fault != SealProvenanceStale { + t.Errorf("fault = %q, want %q; the seal is genuine and only its age is wrong", + pe.Fault, SealProvenanceStale) + } + if pe.LiveVersion <= pe.Version { + t.Errorf("minted version %d, live version %d: the publish did not advance the revision, "+ + "so the staleness check has no substrate", pe.Version, pe.LiveVersion) + } + + // POSITIVE CONTROL. Refusing everything is not a gate. A seal minted NOW, + // from the same expired audit, is still refused — but by the EXPIRY arm, + // which is the arm that should be deciding once the seal itself is current. + fresh, freshErr := sB.ReadHalf("funnelled", HalfSast) + if !errors.Is(freshErr, ErrHalfNotSealed) { + t.Fatalf("ReadHalf on an expired audit = (%v, %v), want a read-gate refusal", fresh, freshErr) + } + if errors.Is(freshErr, ErrSealStale) { + t.Error("a freshly minted seal was refused as STALE; the two arms have collapsed into " + + "one and the staleness refusal above no longer distinguishes anything") + } +} + +// TestTheTwoLatentUnpublishedMutationPathsAreClosed executes the two paths the +// re-verification named, by making DeriveDastStatus fail the only way anything +// can make it fail. +// +// Both used to assign the DAST half's fields and THEN return the derivation's +// error, leaving the fields moved and the published facts behind. Both now +// derive first, so a failure leaves the audit completely untouched — which is a +// stronger property than "it publishes anyway": there is no half-moved state to +// publish. +func TestTheTwoLatentUnpublishedMutationPathsAreClosed(t *testing.T) { + start := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + + for _, tc := range []struct { + name string + // fire drives the path that must fail, and reports the error it gave. + fire func(t *testing.T, s *Sealer, now *time.Time, id string) error + }{ + { + name: "SealHalf DAST branch", + fire: func(t *testing.T, s *Sealer, _ *time.Time, id string) error { + return s.SealHalf(id, HalfDast, HalfStatusSealed) + }, + }, + { + name: "SealDastIfDeadlineDue", + fire: func(t *testing.T, s *Sealer, now *time.Time, id string) error { + *now = start.Add(9 * time.Hour) // well past the DAST deadline + fired, err := s.SealDastIfDeadlineDue(id) + if fired { + t.Error("the forced timeout reported success on a failed derivation") + } + return err + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + now := start + s := NewSealer() + s.SetClock(func() time.Time { return now }) + hour := 3600 + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "latent", StartedAt: start, ClaimTimeoutSeconds: 24 * 3600, + DastEnabled: true, DastDeadlineSeconds: &hour, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + // A consumer holds a seal describing the DAST half as it is now. + held, ok := s.Inspect("latent") + if !ok { + t.Fatal("Inspect: audit missing") + } + if err := HalfReadGate("latent", held.Dast); !errors.Is(err, ErrHalfNotSealed) || + errors.Is(err, ErrSealStale) { + t.Fatalf("the freshly held DAST seal is already stale or unrefused (%v); the "+ + "scenario never starts", err) + } + + a := provCorruptDastProvenance(t, s, "latent") + before := a.factsFor(HalfDast) + + err := tc.fire(t, s, &now, "latent") + if err == nil { + t.Fatal("the derivation did not fail, so this path was never entered") + } + + // 1. ATOMICITY: the mutation did not happen at all. + if got := a.factsFor(HalfDast); got != before { + t.Errorf("a FAILED transition moved the DAST half from %v to %v.\n"+ + " The fallible derivation must run BEFORE the fields are assigned, so a\n"+ + " failure leaves nothing half-moved.", before, got) + } + + // 2. THE INVARIANT: fields and published facts still agree, so no + // held seal can be reading a record that has moved past it. + if d := provFactsDisagreement(a); d != "" { + t.Errorf("RESIDUAL 2 REPRODUCED on %s: %s.\n"+ + " A mutation that does not publish leaves every seal minted before it\n"+ + " reading as CURRENT. That is the staleness gate inverted, not degraded.", + tc.name, d) + } + + // 3. The consequence, in the gate's own terms: the held seal is + // still current, because nothing changed, and is still refused by + // the arm that was refusing it before. + if err := HalfReadGate("latent", held.Dast); errors.Is(err, ErrSealStale) { + t.Errorf("the held seal went STALE although the transition failed and nothing "+ + "moved: %v", err) + } + }) + } +} + +// TestEveryMutatingEntryPointPublishes drives every entry point that can move +// an audit and asserts the fields and the published facts agree after each one. +// +// It is the behavioural companion to the AST guard below: the guard checks that +// mutations are written inside the funnel, this checks that the funnel actually +// keeps the substrate current for the sequences a caller really performs. +func TestEveryMutatingEntryPointPublishes(t *testing.T) { + start := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + now := start + s := NewSealer() + s.SetClock(func() time.Time { return now }) + + hour := 3600 + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "walk", StartedAt: start, ClaimTimeoutSeconds: 24 * 3600, + DastEnabled: true, DastDeadlineSeconds: &hour, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + a := provAudit(t, s, "walk") + + check := func(step string) { + t.Helper() + if d := provFactsDisagreement(a); d != "" { + t.Fatalf("after %s: %s", step, d) + } + } + check("BeginAudit") + + if err := s.RecordDastOutcome("walk", DastOutcome{Provenance: TargetProvenanceBootedClean}); err != nil { + t.Fatalf("RecordDastOutcome: %v", err) + } + check("RecordDastOutcome") + + if err := s.SealHalf("walk", HalfSast, HalfStatusSealed); err != nil { + t.Fatalf("SealHalf(sast): %v", err) + } + check("SealHalf(sast, sealed)") + + now = start.Add(2 * time.Hour) + if fired, err := s.SealDastIfDeadlineDue("walk"); err != nil || !fired { + t.Fatalf("SealDastIfDeadlineDue = (%v, %v), want it to fire", fired, err) + } + check("SealDastIfDeadlineDue") + + if err := s.Consume("walk"); err != nil { + t.Fatalf("Consume: %v", err) + } + check("Consume") + + // A separate audit for the expiry arm, since consumption blocks it. + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "walk-expiry", StartedAt: start, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit(walk-expiry): %v", err) + } + b := provAudit(t, s, "walk-expiry") + if d := provFactsDisagreement(b); d != "" { + t.Fatalf("after BeginAudit(dast disabled): %s", d) + } + now = start.Add(48 * time.Hour) + if expired, err := s.ExpireIfDue("walk-expiry"); err != nil || !expired { + t.Fatalf("ExpireIfDue = (%v, %v), want it to fire", expired, err) + } + if d := provFactsDisagreement(b); d != "" { + t.Fatalf("after ExpireIfDue: %s", d) + } +} + +// --------------------------------------------------------------------------- +// THE FUNNEL CENSUS — the class, not the two instances +// --------------------------------------------------------------------------- + +// provFunnelMutators are the only functions permitted to assign a fact-bearing +// field of an *audit after construction. Each must end in a publish. +func provFunnelMutators() map[string]string { + return map[string]string{ + "audit.setHalf": "assigns one half's status and sealedAt and re-derives anvil/state. " + + "The fallible DastStatus derivation is a PARAMETER, so nothing inside can fail " + + "between the assignment and the publish.", + "audit.setLifecycleState": "assigns the two states DeriveState never produces, " + + "StateConsumed and StateExpired, which are explicit transitions.", + "audit.setDastOutcome": "stores the target-lifecycle facts anvil/dastStatus is derived " + + "from, plus the already-derived value.", + } +} + +// provNonFactAuditFields are the fields of `audit` that are NOT fact-bearing, +// each with the reason it is safe to assign outside the funnel. +// +// The fact set is computed as "every field of `audit` MINUS this list", not as a +// hand-written list of fact-bearing names, and that direction is the point: a +// field added to `audit` tomorrow is fact-bearing BY DEFAULT and must either go +// through the funnel or be exempted here, in writing, by someone who thought +// about it. A hand-written positive list would silently omit it. +func provNonFactAuditFields() map[string]string { + return map[string]string{ + "id": "the audit id, written once at construction and never again", + "startedAt": "scan_run.started_at, fixed at BeginAudit; R.6 forbids recomputing it", + "deadlineAt": "clock 2, computed once at BeginAudit and never recomputed", + "claimTimeoutSeconds": "an AuditConfig input, fixed at BeginAudit", + "dastDeadlineSeconds": "clock 3's input, fixed at BeginAudit", + "dastEnabled": "plan/00-SPINE.md S9-AMENDED's tier bit, fixed at BeginAudit", + "live": "IS the published snapshot. publish() stores it and Forget() clears it, " + + "both through atomic.Pointer methods rather than assignment; it is the thing the " + + "funnel maintains, not a fact the funnel must maintain it for.", + } +} + +// provFunnelFinding is one violation the scanner reports. +type provFunnelFinding struct { + kind string // "assign" (a fact-bearing write outside the funnel) | "publish" + fn string // "Func" or "Recv.Method" + what string +} + +func (f provFunnelFinding) String() string { return f.kind + ":" + f.fn + ":" + f.what } + +// provScanFunnel walks parsed source and reports every fact-bearing assignment +// outside the funnel, plus every funnel mutator that does not end in a publish. +// +// It is a separate function from the test so the negative control can run the +// SAME detector over source that deliberately contains the defects. A guard that +// has never been observed to fail has not been tested, and this package has +// paid for that lesson three times. +func provScanFunnel(files map[string]*ast.File, factFields, mutators map[string]bool) (findings []provFunnelFinding, seenMutators map[string]bool) { + seenMutators = map[string]bool{} + + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + slices.Sort(paths) // deterministic order; ranging a map to report is the old determinism bug + + for _, path := range paths { + for _, decl := range files[path].Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + name := fn.Name.Name + if fn.Recv != nil && len(fn.Recv.List) == 1 { + if recv := gateBaseTypeName(fn.Recv.List[0].Type); recv != "" { + name = recv + "." + name + } + } + + if mutators[name] { + seenMutators[name] = true + findings = append(findings, provCheckMutatorPublishes(name, fn.Body)...) + continue // assignments here are the point of the function + } + + var hits []string + ast.Inspect(fn.Body, func(n ast.Node) bool { + var targets []ast.Expr + switch v := n.(type) { + case *ast.AssignStmt: + targets = v.Lhs + case *ast.IncDecStmt: + targets = []ast.Expr{v.X} + } + for _, target := range targets { + sel, ok := target.(*ast.SelectorExpr) + if ok && factFields[sel.Sel.Name] && !slices.Contains(hits, sel.Sel.Name) { + hits = append(hits, sel.Sel.Name) + } + } + return true + }) + slices.Sort(hits) + for _, field := range hits { + findings = append(findings, provFunnelFinding{kind: "assign", fn: name, what: field}) + } + } + } + return findings, seenMutators +} + +// provCheckMutatorPublishes enforces the shape that makes "publishes on every +// return path" checkable at all: the body's LAST statement is a call to +// publish, and the body contains no return. +// +// A straight-line body with the publish last has exactly one return path, so +// the two conditions together ARE "every return path publishes". That works +// only because the three mutators are three, four and three statements long. If +// one ever grows a branch this test will demand it be split rather than start +// reasoning about control flow — which is the right trade: a checkable shape +// beats a clever checker. +func provCheckMutatorPublishes(name string, body *ast.BlockStmt) []provFunnelFinding { + var out []provFunnelFinding + + ast.Inspect(body, func(n ast.Node) bool { + if _, ok := n.(*ast.ReturnStmt); ok { + out = append(out, provFunnelFinding{kind: "publish", fn: name, what: "returns early"}) + } + return true + }) + + last := "" + if n := len(body.List); n > 0 { + if stmt, ok := body.List[n-1].(*ast.ExprStmt); ok { + if call, ok := stmt.X.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + last = sel.Sel.Name + } + } + } + } + if last != "publish" { + out = append(out, provFunnelFinding{kind: "publish", fn: name, what: "does not end in publish"}) + } + return out +} + +// provAuditFactFields computes the fact-bearing field set from the `audit` +// struct declaration itself, minus provNonFactAuditFields. +func provAuditFactFields(t *testing.T, files map[string]*ast.File) map[string]bool { + t.Helper() + + var fields []string + for _, file := range files { + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != "audit" { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + for _, f := range st.Fields.List { + for _, id := range f.Names { + fields = append(fields, id.Name) + } + } + return false + }) + } + if len(fields) == 0 { + t.Fatal("found no fields on the `audit` struct; this test asserts nothing unless it " + + "reads the type it is about") + } + + exempt := provNonFactAuditFields() + out := map[string]bool{} + for _, f := range fields { + if reason, ok := exempt[f]; ok { + if strings.TrimSpace(reason) == "" { + t.Errorf("audit.%s is exempted from the fact-bearing set with an empty reason", f) + } + continue + } + out[f] = true + } + for f := range exempt { + if !slices.Contains(fields, f) { + t.Errorf("provNonFactAuditFields exempts audit.%s, which is not a field of `audit` any "+ + "more. Delete the entry — an exemption that outlives its field is a standing "+ + "waiver nobody has read.", f) + } + } + if len(out) == 0 { + t.Fatal("every field of `audit` is exempted; the fact-bearing set is empty and this test " + + "cannot fail") + } + return out +} + +// TestEveryFactBearingAssignmentGoesThroughTheMutationFunnel is the CLASS fix +// for residual 2, in place of fixing two instances. +// +// It reads this package as data and fails when a fact-bearing field of an +// *audit is assigned anywhere outside the three funnel mutators, or when one of +// those mutators stops publishing on every return path. +// +// WHY A CENSUS AND NOT TWO FIXES. The re-verifier named one path and said there +// were two; there were. That is the fourth time this package has been handed a +// list of instances of a shape (four read-gate bypasses, three guard defeats), +// and the pattern — not any one instance — has been the defect every time. The +// two paths are fixed in sealing.go; this is what stops the third. +// +// --------------------------------------------------------------------------- +// KNOWN LIMITS — read these before trusting a green run +// --------------------------------------------------------------------------- +// +// This is a GUARD, not a proof. It is a syntactic walk, and the following are +// OPEN, deliberately and with the reasoning written down rather than implied. +// +// 1. IT MATCHES ON FIELD NAME, NOT ON TYPE. An assignment to `x.state` is +// flagged whichever struct x is — a false POSITIVE, which is the safe +// direction and has an obvious fix (rename, or exempt with a reason). What +// it cannot do is know that some future `x.state` is a different field. +// Closing this needs go/types, which this package's guards have so far +// avoided for the sake of a test that runs anywhere. +// +// 2. IT CANNOT SEE A WRITE THROUGH A POINTER ALIAS. `p := &a.dastStatus; +// *p = HalfStatusSealed` compiles, mutates, and is invisible here: the +// assignment's left-hand side is a StarExpr, not a SelectorExpr. So is a +// write through reflect, and so is `*a = audit{...}`, which replaces every +// field at once. None of these is a shape anyone writes by accident, which +// is exactly the limit: this catches the ACCIDENT, and the accident is what +// happened twice. +// +// 3. CONSTRUCTION IS EXEMPT. `&audit{...}` composite literals are not +// inspected, because BeginAudit fills one while it is still unreachable +// from the Sealer and no seal can exist to be made stale. If an `audit` +// value is ever REUSED — reset and handed back out — that exemption becomes +// wrong and nothing here will notice. +// +// 4. "PUBLISHES ON EVERY RETURN PATH" IS CHECKED AS A SHAPE, not as control +// flow: last statement is a publish, and there is no return anywhere. It is +// equivalent for straight-line bodies and for nothing else. A mutator that +// grows an `if` fails this test even when it is correct — on purpose, so the +// answer is to split the function rather than to teach the checker. +// +// 5. IT DOES NOT CHECK THAT publish() PUBLISHES THE RIGHT THING. That is +// behaviour, and TestEveryMutatingEntryPointPublishes above is what covers +// it, by comparing the published facts against the fields after every +// mutating entry point. +// +// 6. IT WALKS THIS PACKAGE ONLY. That happens to be complete for this field +// set — every field of `audit` is unexported, so no other package can +// assign one at all — and it is the one place this guard is stronger than a +// convention. It would stop being complete the moment a fact-bearing field +// were exported, which is a change nobody should make and nothing here +// forbids. +func TestEveryFactBearingAssignmentGoesThroughTheMutationFunnel(t *testing.T) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parsing the package source: %v", err) + } + if len(pkgs) == 0 { + t.Fatal("parsed no packages; this test asserts nothing unless it reads the source") + } + + files := map[string]*ast.File{} + for _, pkg := range pkgs { + for path, file := range pkg.Files { + files[filepath.Base(path)] = file + } + } + + factFields := provAuditFactFields(t, files) + mutatorReasons := provFunnelMutators() + mutators := map[string]bool{} + for name := range mutatorReasons { + mutators[name] = true + } + + findings, seen := provScanFunnel(files, factFields, mutators) + for _, f := range findings { + switch f.kind { + case "assign": + t.Errorf("%s assigns the fact-bearing field audit.%s outside the mutation funnel.\n"+ + " EVERY MUTATION MUST PUBLISH. The staleness arm of the read gate compares a\n"+ + " held seal against the audit's published facts; a mutation that does not\n"+ + " publish leaves every seal minted before it reading as CURRENT. That is not\n"+ + " a degraded check, it is the check inverted, and it fails OPEN.\n"+ + " Route the write through one of %v, which publish by construction.", + f.fn, f.what, slices.Sorted(maps.Keys(mutatorReasons))) + case "publish": + t.Errorf("the funnel mutator %s %s.\n"+ + " Its whole reason to exist is that a fact-bearing write and its publish are\n"+ + " ONE operation. A mutator with a return before its publish is the defect this\n"+ + " funnel was built to make unwritable, relocated into the funnel itself.", + f.fn, f.what) + } + } + + for name, reason := range mutatorReasons { + if !seen[name] { + t.Errorf("provFunnelMutators names %q, which is not a function in the current "+ + "source (reason on file: %s). Delete the entry — a funnel that names mutators "+ + "which no longer exist has stopped describing the code.", name, reason) + } + } + t.Logf("mutation funnel: %d fact-bearing fields, %d mutators, %d findings", + len(factFields), len(seen), len(findings)) +} + +// provFunnelProbeSource is the negative control: residual 2's two defects and +// one innocent function, as source text. +// +// sealHalfUnpublishedDast is Sealer.SealHalf's DAST branch as it stood, line for +// line — assign, derive, return the error from between the assignment and the +// publish. +// +// setHalf and setLifecycleState are the two ways a funnel mutator can stop +// being one: losing its publish, and growing a return in front of it. +// +// innocentReader is the false-positive control. It READS every fact-bearing +// field, compares them, and assigns a local variable — and must not fire, or +// the detector would flag half of sealing.go. +const provFunnelProbeSource = `package record + +import "time" + +func (s *Sealer) sealHalfUnpublishedDast(a *audit, status HalfStatus, sealedAt *time.Time) error { + a.dastStatus = status + a.dastSealedAt = sealedAt + derived, derr := DeriveDastStatus(a.dastStatus, a.dastOutcome) + if derr != nil { + return derr + } + a.dastDerived = derived + a.state = DeriveState(a.sastStatus, a.dastStatus) + a.publish() + return nil +} + +func (a *audit) setHalf(half Half, status HalfStatus, sealedAt *time.Time, dastDerived DastStatus) { + a.sastStatus = status + a.sastSealedAt = sealedAt + a.dastDerived = dastDerived + a.state = DeriveState(a.sastStatus, a.dastStatus) +} + +func (a *audit) setLifecycleState(state State) { + a.state = state + if state == StateConsumed { + return + } + a.publish() +} + +func (a *audit) setDastOutcome(o DastOutcome, dastDerived DastStatus) { + a.dastOutcome = o + a.dastDerived = dastDerived + a.publish() +} + +func innocentReader(a *audit) State { + seen := a.state + if a.dastStatus == a.sastStatus && a.dastSealedAt == a.sastSealedAt { + seen = a.state + } + return seen +} +` + +// TestTheFunnelDetectorCatchesTheDefectItWasWrittenFor runs the SAME scanner +// the census uses over source that deliberately contains residual 2's shape. +// +// Without it the census would be one more guard that has never been seen to +// fail. That is not a hypothetical failure mode in this package: the previous +// read-gate guard counted bodies carrying BOTH arms, so the one-arm defect it +// was written for went through it twice. +func TestTheFunnelDetectorCatchesTheDefectItWasWrittenFor(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "zz_funnel_probe.go", provFunnelProbeSource, 0) + if err != nil { + t.Fatalf("parsing the synthetic probe: %v", err) + } + + factFields := map[string]bool{ + "state": true, "sastStatus": true, "sastSealedAt": true, + "dastStatus": true, "dastSealedAt": true, "dastDerived": true, "dastOutcome": true, + } + mutators := map[string]bool{ + "audit.setHalf": true, "audit.setLifecycleState": true, "audit.setDastOutcome": true, + } + + findings, seen := provScanFunnel(map[string]*ast.File{"zz_funnel_probe.go": file}, factFields, mutators) + + got := map[string]bool{} + for _, f := range findings { + got[f.String()] = true + } + want := []string{ + // residual 2's shape: four fact-bearing writes in a non-mutator whose + // error return sits between the first of them and the publish. + "assign:Sealer.sealHalfUnpublishedDast:dastDerived", + "assign:Sealer.sealHalfUnpublishedDast:dastSealedAt", + "assign:Sealer.sealHalfUnpublishedDast:dastStatus", + "assign:Sealer.sealHalfUnpublishedDast:state", + // a mutator that lost its publish, and one that returns in front of it. + "publish:audit.setHalf:does not end in publish", + "publish:audit.setLifecycleState:returns early", + } + for _, w := range want { + if !got[w] { + t.Errorf("the detector missed %q. It is the shape the census exists to catch, so a "+ + "census that cannot see it here cannot see it in sealing.go either.\n"+ + " reported: %v", w, slices.Sorted(maps.Keys(got))) + } + delete(got, w) + } + for extra := range got { + t.Errorf("the detector reported %q, which the probe does not contain. innocentReader "+ + "only READS the fact-bearing fields and assigns a local; flagging a read would "+ + "flag half of sealing.go and the census would be unusable.", extra) + } + if !seen["audit.setDastOutcome"] { + t.Error("the probe's well-formed mutator was not recognised as a mutator; the detector " + + "would report the whole funnel as missing") + } + if len(seen) != len(mutators) { + t.Errorf("the detector saw %d of %d mutators", len(seen), len(mutators)) + } +} + +// --------------------------------------------------------------------------- +// CLOCK 3 — the DAST deadline now has an authoritative substrate +// --------------------------------------------------------------------------- + +// TestClockThreeIsDueCheckedAgainstTheSealersOwnCopy is CRITIQUE O.4 blocker +// 2's probe P10, second half, moved to the owner: the due-check is against +// `startedAt + dastDeadlineSeconds` as BeginAudit fixed them, so nothing a +// caller holds can move it. +func TestClockThreeIsDueCheckedAgainstTheSealersOwnCopy(t *testing.T) { + start := time.Date(2026, 8, 7, 9, 0, 0, 0, time.UTC) + now := start + s := NewSealer() + s.SetClock(func() time.Time { return now }) + + four := 4 * 3600 + seal, err := s.BeginAudit(AuditConfig{ + AuditID: "clock3", StartedAt: start, ClaimTimeoutSeconds: 24 * 3600, + DastEnabled: true, DastDeadlineSeconds: &four, + }) + if err != nil { + t.Fatalf("BeginAudit: %v", err) + } + + due, ok := seal.DastDeadlineAt() + if !ok { + t.Fatal("AuditSeal.DastDeadlineAt reports no DAST deadline for an audit that has one") + } + if want := start.Add(4 * time.Hour); !due.Equal(want) { + t.Errorf("clock 3 = %s, want %s (started_at + dast_deadline_seconds)", due, want) + } + + // The target booted and the half is scanning. Without this the derivation + // reports skipped_no_manifest — provenance outranks the half's status, by + // design — and clock 3's own outcome would be invisible. + if err := s.RecordDastOutcome("clock3", DastOutcome{Provenance: TargetProvenanceBootedClean}); err != nil { + t.Fatalf("RecordDastOutcome: %v", err) + } + + // Before the deadline: nothing happens, and it is not an error. + now = start.Add(3 * time.Hour) + if fired, err := s.SealDastIfDeadlineDue("clock3"); err != nil || fired { + t.Fatalf("clock 3 fired %v (err %v) three hours into a four-hour deadline", fired, err) + } + + // A caller re-deriving from its own snapshot cannot move it: there is no + // deadline FIELD to assign to, only StartedAt and DastDeadlineSeconds, + // both fixed at BeginAudit. Mutating the caller's copy of the snapshot + // changes what that copy says and nothing about what fires. + tampered, _ := s.Inspect("clock3") + hundred := 100 * 3600 + tampered.DastDeadlineSeconds = &hundred + tampered.StartedAt = start.Add(96 * time.Hour) + if moved, _ := tampered.DastDeadlineAt(); moved.Equal(due) { + t.Fatal("the probe did not actually move the caller's copy; it proves nothing") + } + now = start.Add(5 * time.Hour) + fired, err := s.SealDastIfDeadlineDue("clock3") + if err != nil { + t.Fatalf("SealDastIfDeadlineDue: %v", err) + } + if !fired { + t.Fatal("O4-B2 REPRODUCED: clock 3 did not fire an hour past the deadline after a " + + "caller pushed its own copy of the deadline out. The Sealer must hold its own.") + } + + live, _ := s.Inspect("clock3") + if live.Dast.Status != HalfStatusTimedOut { + t.Errorf("DAST half is %q after clock 3 fired, want %q", live.Dast.Status, HalfStatusTimedOut) + } + if live.Dast.SealedAt != nil { + t.Error("a timed-out half carries a sealedAt; only a cleanly sealed half may") + } + if live.DastStatus != DastStatusTimedOut { + t.Errorf("anvil/dastStatus = %q, want %q", live.DastStatus, DastStatusTimedOut) + } + if live.Dast.Readable() { + t.Error("a timed-out DAST half is readable; terminal is not readable") + } + + // Clock 3 firing does not move clock 2 by one nanosecond. + if !live.DeadlineAt.Equal(seal.DeadlineAt) { + t.Errorf("deadline_at moved from %s to %s when clock 3 fired; the clocks are independent", + seal.DeadlineAt, live.DeadlineAt) + } + + // Firing twice is a no-op: the half is already terminal. + if again, err := s.SealDastIfDeadlineDue("clock3"); err != nil || again { + t.Errorf("a second due-check re-fired (%v, %v); a half seals once", again, err) + } +} + +// TestClockThreeIsAbsentWhenNoDastDeadlineIsConfigured: the common +// core-`anvil` install has no DAST tier, no DAST deadline, and must not have +// its already-skipped half re-sealed by a tick. +func TestClockThreeIsAbsentWhenNoDastDeadlineIsConfigured(t *testing.T) { + start := time.Date(2026, 8, 7, 9, 0, 0, 0, time.UTC) + now := start + s := NewSealer() + s.SetClock(func() time.Time { return now }) + + if _, err := s.BeginAudit(AuditConfig{ + AuditID: "noclock3", StartedAt: start, ClaimTimeoutSeconds: 3600, DastEnabled: false, + }); err != nil { + t.Fatalf("BeginAudit: %v", err) + } + snap, _ := s.Inspect("noclock3") + if _, ok := snap.DastDeadlineAt(); ok { + t.Error("a DAST-disabled audit reports a DAST deadline") + } + + now = start.Add(10000 * time.Hour) + if fired, err := s.SealDastIfDeadlineDue("noclock3"); err != nil || fired { + t.Errorf("clock 3 fired (%v, %v) on an audit with no DAST deadline", fired, err) + } + after, _ := s.Inspect("noclock3") + if after.Dast.Status != HalfStatusSkipped { + t.Errorf("the skipped DAST half became %q", after.Dast.Status) + } + + // And an unknown audit is an error, not a silent false: a tick against an + // audit nobody began is a bug in the caller. + if _, err := s.SealDastIfDeadlineDue("no-such-audit"); !errors.Is(err, ErrUnknownAudit) { + t.Errorf("SealDastIfDeadlineDue on an unknown audit = %v, want ErrUnknownAudit", err) + } +} + +// TestComputeDastDeadlineIsTheOneFormula pins the formula itself, so a second +// spelling of clock 3 cannot appear without this failing. +func TestComputeDastDeadlineIsTheOneFormula(t *testing.T) { + start := time.Date(2026, 8, 7, 9, 0, 0, 0, time.UTC) + if _, ok := ComputeDastDeadline(start, nil); ok { + t.Error("a nil dast_deadline_seconds produced a deadline") + } + zero, neg := 0, -1 + if _, ok := ComputeDastDeadline(start, &zero); ok { + t.Error("a zero dast_deadline_seconds produced a deadline; the schema requires NULL or > 0") + } + if _, ok := ComputeDastDeadline(start, &neg); ok { + t.Error("a negative dast_deadline_seconds produced a deadline") + } + n := 90 + got, ok := ComputeDastDeadline(start, &n) + if !ok || !got.Equal(start.Add(90*time.Second)) { + t.Errorf("ComputeDastDeadline = (%s, %v), want %s", got, ok, start.Add(90*time.Second)) + } +} diff --git a/internal/record/readpath.go b/internal/record/readpath.go index c55fd5c..508d98b 100644 --- a/internal/record/readpath.go +++ b/internal/record/readpath.go @@ -868,7 +868,7 @@ func (rd *Reader) ManifestFromLog(l *SARIFLog) (Manifest, error) { Half: run.Properties.Half, Status: run.Properties.Status, Readable: seal.Readable(), - ReadRefusal: halfReadRefusal(seal), + ReadRefusal: halfReadRefusal(seal).reason, Results: len(run.Results), Cards: cardsPerHalf[run.Properties.Half], Tool: run.Tool.Driver.Name, diff --git a/internal/record/readpath_test.go b/internal/record/readpath_test.go index 7ea2e7a..19bcf26 100644 --- a/internal/record/readpath_test.go +++ b/internal/record/readpath_test.go @@ -2024,10 +2024,12 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // 13 and it is closed. // - Reaching the gate in the call graph is not the same as OBEYING it with // the RIGHT seal. The call must now be a call and its result must be -// used, but nothing here checks that the seal handed to the gate is the -// seal of the half whose results are being returned. That is attacks 14 -// and 15, and they are open: read the KNOWN LIMITS section before you -// trust a green run. +// used, but nothing THIS TEST checks pairs the seal handed to the gate +// with the half whose results are being returned. That is attacks 14 and +// 15. Attack 14 is now closed, at RUNTIME rather than here — HalfSeal +// carries unexported provenance and HalfReadGate refuses a seal no +// producer minted — and attack 15 is still OPEN. Read the KNOWN LIMITS +// section before you trust a green run. // - `readOrder` counts as reaching the gate, so if readOrder ITSELF were // rewritten to ask one arm — which is precisely what CRITIQUE-03 M1 was — // this test would still pass. MEASURED, by putting that defect back: this @@ -2080,6 +2082,13 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // without a different technique (go/types + SSA def-use, or the runtime // redesign described under attack 15, which is the one worth doing). // +// ONE OF THOSE TWO GAPS IS NOW COVERED ELSEWHERE, and it is worth being +// precise about which. "The RIGHT SEAL" is now enforced at runtime, by seal +// provenance in sealing.go: a seal the caller built, edited, or held across a +// state change is refused by HalfReadGate itself. "The RIGHT HALVES" is not, +// and is attack 15. This test is unchanged by either — it never checked +// either one — so a green run here means precisely what it meant before. +// // THREE FURTHER OPEN HOLES, found by the second adversary after the six // cheap ones were closed. Not fixed; recorded so the list is honest. // @@ -2127,16 +2136,23 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // results it went on to return". Those are dataflow questions — which value // flowed into which parameter, and which values flowed out — and an AST // reachability walk that pretended to answer them would hand out exactly the -// false confidence this whole exercise exists to avoid. Two attacks live in -// that gap. Both were run against this guard. Both won. Both still win. +// false confidence this whole exercise exists to avoid. Two attacks lived in +// that gap. Both were run against this guard and both won. ONE OF THEM — the +// fabricated seal — is now closed, in the production code rather than here. +// The other still wins. // // --------------------------------------------------------------------------- -// LIMIT 1 (adversary attack 14) — THE FABRICATED SEAL +// LIMIT 1 (adversary attack 14) — THE FABRICATED SEAL — **CLOSED AT RUNTIME** // --------------------------------------------------------------------------- // -// Call the gate. Check the error. Return the results. But hand the gate a -// HalfSeal you built yourself rather than the seal of the half you are about -// to read: +// STILL OPEN TO THIS TEST, and that has not changed: everything below about +// what the static analysis can and cannot see is still true. What changed is +// that the gate itself now refuses the attack, so the hole this test cannot +// see is no longer a hole anything can walk through. +// +// THE ATTACK. Call the gate. Check the error. Return the results. But hand the +// gate a HalfSeal you built yourself rather than the seal of the half you are +// about to read: // // func (rd *Reader) LeakViaFabricatedSeal(l *SARIFLog) []Result { // seal := HalfSeal{Half: HalfSast, Status: HalfStatusSealed, ...} @@ -2152,28 +2168,79 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // // To this guard that is a textbook obedient function: a real *ast.CallExpr on // HalfReadGate, the returned error consumed by an `if`, an early return on -// refusal. Every structural property it checks is satisfied. The gate was -// asked a question about a half that does not exist and it answered honestly. -// -// WHAT A READER MUST NOT CONCLUDE: that a green -// TestResultReachingEntryPointsAreGated means the gate was consulted ABOUT the -// data returned. It means a gate call happened somewhere in the call graph and -// its result was not thrown away. The seal's provenance is unchecked. -// -// WHAT WOULD ACTUALLY CATCH IT: dataflow. The HalfSeal argument at the call -// site must be traced to its definition and required to originate from -// halfSealOfRun (or Sealer.HalfSeal) applied to the same record the results -// are read from — a def-use chain, needing go/types and SSA, not an -// ast.Inspect. Or, cheaper and stronger, a RUNTIME assertion: give HalfSeal an -// unexported provenance field that only halfSealOfRun and the Sealer can set, -// and have HalfReadGate refuse any seal without it. A fabricated composite -// literal then cannot be handed to the gate at all, and the hole closes in the -// production code rather than in a test that inspects it. +// refusal. Every structural property it checks is satisfied. +// +// IT WAS NOT HYPOTHETICAL, AND IT DID NOT TAKE AN ADVERSARY. CRITIQUE O.4 +// found this exact shape occurring NATURALLY in internal/scanctl within hours: +// AuditRecord.HalfSeal assembled a record.HalfSeal out of caller-held fields +// (the half's status, the record's state) with no refresh path and handed it +// to the gate, which then answered truthfully about a seal that could be +// arbitrarily stale. Nobody was attacking anything. It is simply the natural +// way to write it, which is why documenting the hole was not enough. +// +// HOW IT IS CLOSED (sealing.go, "SEAL PROVENANCE"). HalfSeal carries an +// UNEXPORTED field, prov. Exactly two functions set it — +// +// halfSealOfRun the record-side projection over an assembled *SARIFLog +// audit.halfSeal the Sealer's projection over a live in-flight audit +// +// — and HalfReadGate refuses any seal for which it is nil, with a distinct +// typed reason (*SealProvenanceError, matching errors.Is(err, +// ErrSealNotFromProducer) as well as ErrHalfNotSealed) that names both +// producers. The enforcement is the COMPILER: an unexported field cannot +// appear in a composite literal outside internal/record, so the fabricated +// seal above cannot be built by a consumer at all, and inside this package +// TestOnlyTwoProducersStampProvenance fails if a third producer appears. +// +// PROVENANCE ALSO CARRIES STALENESS, WHICH IS THE PART THAT MATTERED. Marking +// a seal "a producer made this" would not have caught O.4: scanctl's seal was +// made by a legitimate-looking projection and then held across a state change. +// So prov holds a LIVE HANDLE on what the seal was minted from — the +// (*SARIFLog, *Run) for the record side, the *audit plus a published revision +// for the Sealer — together with the facts as they read at minting, and the +// gate RE-READS the origin on every call. Four faults come out of it: absent +// (nobody minted it), tampered (minted, then an exported field was assigned +// to), origin_gone (Sealer.Forget dropped the audit), and stale (the origin +// moved on). All but the first match errors.Is(err, ErrSealStale). +// +// PROVEN, NOT ASSERTED. provenance_test.go's TestGateRefusesAHandBuiltHalfSeal +// rebuilds scanctl's literal field for field and requires the gate to refuse +// it, with a POSITIVE CONTROL that reads successfully through a real seal +// carrying identical facts — so the test cannot pass against a gate that has +// simply been broken shut. Each of the other three faults has its own test and +// its own control. Every one of them was MEASURED failing by neutering the +// check it covers. +// +// WHAT IS STILL TRUE OF THIS TEST: a green +// TestResultReachingEntryPointsAreGated still does not mean the gate was +// consulted ABOUT the data returned. It means a gate call happened somewhere +// in the call graph and its result was not thrown away. The runtime check, not +// this one, is what makes the seal trustworthy. +// +// WHAT REMAINS OPEN WITHIN LIMIT 1. Provenance binds a seal to an origin; it +// does not bind the origin to the RECORD THE CALLER IS ABOUT TO READ. A +// function handed two records can mint a genuine seal from record A, pass the +// gate, and return record B's results. Catching that is the same dataflow +// problem as before — the def-use chain from the gate's argument to the +// results that leave — and nothing here or in sealing.go attempts it. It is +// also, unlike the fabricated seal, not a shape anyone has written by accident. +// Separately, the record-side origin has no revision counter (a *SARIFLog is a +// plain value), so its currency check is content equality alone: a record +// mutated back to the facts it had at minting reads as current. Statuses and +// states only move forwards, so that means un-sealing a sealed half, which +// nothing in this package does. // // --------------------------------------------------------------------------- -// LIMIT 2 (adversary attack 15) — OBEY FOR ONE HALF, RETURN BOTH +// LIMIT 2 (adversary attack 15) — OBEY FOR ONE HALF, RETURN BOTH — **OPEN** // --------------------------------------------------------------------------- // +// STILL OPEN. Seal provenance closed attack 14 and did NOT close this one, and +// the reason is worth stating before the attack rather than after it: attack 15 +// never fabricates anything. Its seal is genuine, freshly minted, current, and +// about a half that really is readable. Provenance checks where a seal came +// from and whether it is still live. It has nothing to say about a caller that +// asks an honest question about one half and then hands out two. +// // Call the gate. Check the error. Obey it — for the SAST half. Then return // every result in the record, DAST included: // @@ -2194,11 +2261,23 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // return out // } // -// Here the seal is genuine — halfSealOfRun over the real record — so even the -// provenance idea above would not fire. The defect is that the SET of halves -// the gate was consulted about is smaller than the SET of halves whose results -// were returned. An unsealed DAST half walks out behind a sealed SAST half's -// permission. +// Here the seal is genuine — halfSealOfRun over the real record — so the +// provenance check above does not fire, and MEASURED that is exactly what +// happens: run this body against a record with a sealed SAST half and an +// unsealed DAST half and every DAST result still walks out. The defect is that +// the SET of halves the gate was consulted about is smaller than the SET of +// halves whose results were returned. An unsealed DAST half walks out behind a +// sealed SAST half's permission. +// +// DOES PROVENANCE NARROW IT AT ALL? Only at the very edge, and not in a way +// worth crediting. One VARIANT is now caught: obtain a real SAST seal, relabel +// it (`seal.Half = HalfDast`) and present it as the DAST half's permission. +// Editing an exported field on a minted seal is the "tampered" fault, and +// TestGateRefusesAnEditedSeal covers it. But attack 15 as written never needs +// to relabel anything — it does not present a DAST seal at all, it simply +// stops asking — so the variant that is closed is not the attack. Anyone +// tempted to record this limit as "partially closed" should read the body +// above again: not one line of it changes. // // WHAT A READER MUST NOT CONCLUDE: that a gated entry point is gated for every // half it can return. This guard counts gate calls; it does not pair them with @@ -2245,6 +2324,15 @@ func TestReadGateOpensOnASealedAudit(t *testing.T) { // points that are IN gateAuditedEntryPoints. That list is maintained by hand. // The source guard's whole job is to notice when something is missing from it. // +// THE PAIR IS NOW A TRIO, and the third member is not a test. Seal provenance +// (sealing.go) is a check the SHIPPED CODE performs on every gate call, so +// unlike both guards above it covers entry points nobody listed, callers in +// other packages, and code written after this comment. It answers exactly one +// question — "is this seal a live seal from a real producer?" — and it answers +// it always. What it does not answer is which halves the caller went on to +// return, which is why attack 15 is still in this section rather than in the +// closed one. +// // So the honest summary of the pair is: the source guard says "a new exported // function that reaches results without asking the gate cannot be added // silently", and the behavioural guard says "the entry points we know about @@ -3049,11 +3137,16 @@ func TestTheSourceGuardCatchesTheLeaksThatDefeatedItsPredecessor(t *testing.T) { // =========================================================================== // // An adversary ran sixteen attacks at this guard and won eight. Six of the -// eight are closed, and each closed shape lives below as source text so the -// guard is re-defeated-and-caught on every run. The two that are NOT closed — -// the fabricated seal and partial obedience — are documented in KNOWN LIMITS -// above and deliberately have no probe here, because a probe that passed would -// be a lie about what this analysis can see. +// eight are closed AGAINST THIS ANALYSIS, and each closed shape lives below as +// source text so the guard is re-defeated-and-caught on every run. +// +// The other two — the fabricated seal (14) and partial obedience (15) — are +// documented in KNOWN LIMITS above and deliberately have no probe here, +// because a probe that passed would be a lie about what this analysis can see. +// That is still true of both, INCLUDING 14: attack 14 is now closed at +// RUNTIME, by seal provenance in sealing.go, and this analysis cannot see that +// either. Its probe lives with the mechanism that closed it, in +// provenance_test.go, where it belongs. // // Every function below is written the way the bypass would actually be // written. None of them is exotic; that is the point. diff --git a/internal/record/sealing.go b/internal/record/sealing.go index db266ed..b9f817b 100644 --- a/internal/record/sealing.go +++ b/internal/record/sealing.go @@ -66,6 +66,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" ) @@ -108,6 +109,19 @@ var ( // ErrNotBothSealed: Consume was called before both halves reached a // terminal status. ErrNotBothSealed = errors.New("record: audit has not reached both_sealed") + + // ErrSealNotFromProducer: THE PROVENANCE GATE. A HalfSeal reached + // HalfReadGate without having been minted by one of the two legitimate + // producers — i.e. it was built field by field by its caller. See the + // seal-provenance section below. + ErrSealNotFromProducer = errors.New("record: half seal was not obtained from a producer; consumer read refused") + + // ErrSealStale: THE STALENESS GATE. A HalfSeal WAS minted by a legitimate + // producer, but the live object it was minted from no longer reads the + // way it did at minting — or the seal's own exported fields were changed + // after minting, or the origin is gone. The gate refuses rather than + // answering truthfully about a snapshot nobody has refreshed. + ErrSealStale = errors.New("record: half seal no longer matches the record it was minted from; consumer read refused") ) // SealingError reports a refused sealing or lifecycle transition. It names @@ -156,6 +170,17 @@ type ReadGateError struct { Status HalfStatus // that half's actual anvil/status State State // the audit's anvil/state Reason string + + // Cause is the DISTINCT reason, when the refusal was not one of the two + // historical arms (unsealed status, expired audit). It is a + // *SealProvenanceError when the seal itself was untrustworthy — forged or + // stale — and nil otherwise. + // + // It is separate from Reason because "this half is not sealed" and "I do + // not believe this seal" are different facts about different objects, and + // a caller that reports the first when it meant the second sends an + // operator to look at the wrong thing. + Cause error } func (e *ReadGateError) Error() string { @@ -164,8 +189,114 @@ func (e *ReadGateError) Error() string { e.Half, e.AuditID, e.Status, e.State, e.Reason, HalfStatusSealed) } -// Unwrap makes every read refusal match errors.Is(err, ErrHalfNotSealed). -func (e *ReadGateError) Unwrap() error { return ErrHalfNotSealed } +// Unwrap makes every read refusal match errors.Is(err, ErrHalfNotSealed) — +// including the provenance refusals, because a seal the gate does not believe +// is a half the consumer may not read — while ALSO matching the distinct +// sentinel when there is one. +func (e *ReadGateError) Unwrap() []error { + if e.Cause == nil { + return []error{ErrHalfNotSealed} + } + return []error{ErrHalfNotSealed, e.Cause} +} + +// SealProvenanceFault names which provenance rule refused. +type SealProvenanceFault string + +const ( + // SealProvenanceAbsent: the HalfSeal was never minted by a producer. A + // composite literal, a zero value, or a struct decoded from JSON. + SealProvenanceAbsent SealProvenanceFault = "absent" + + // SealProvenanceTampered: the seal WAS minted, and then one of its + // exported fields was assigned to. Copying a seal is legal; editing one + // is not. + SealProvenanceTampered SealProvenanceFault = "tampered" + + // SealProvenanceOriginGone: the object the seal was minted from can no + // longer be consulted — Sealer.Forget dropped the audit. + SealProvenanceOriginGone SealProvenanceFault = "origin_gone" + + // SealProvenanceStale: the origin is still there and no longer reads the + // way it did at minting. THIS is CRITIQUE O.4's defect: a seal minted + // legitimately and held across a state change. + SealProvenanceStale SealProvenanceFault = "stale" +) + +// SealProvenanceError is the typed reason a HalfSeal was refused for what it +// IS rather than for what it SAYS. +// +// It is the runtime half of adversary attack 14 (readpath_test.go, KNOWN +// LIMITS): the static guard cannot tell "called the gate" from "called the +// gate about a seal it made up", so the gate now refuses to answer about a +// seal it did not mint. Every instance of this error carries the two +// legitimate producers by name, because the caller that trips it needs to know +// where a real seal comes from, not merely that its own was rejected. +type SealProvenanceError struct { + AuditID string + Half Half + Fault SealProvenanceFault + + // Producer is the producer that minted the seal, empty when the fault is + // SealProvenanceAbsent (nobody minted it). + Producer string + + // Version is the origin's revision at minting, and LiveVersion the + // origin's revision now. Both are zero for the record-side projection, + // which has no revision substrate — see halfSealOfRun. + Version uint64 + LiveVersion uint64 + + // Minted and Live describe the seal's facts at minting and at the moment + // the gate was asked. They differ exactly when the seal is stale. + Minted string + Live string +} + +func (e *SealProvenanceError) Error() string { + switch e.Fault { + case SealProvenanceAbsent: + return fmt.Sprintf( + "record: the %s half seal offered for audit %q carries no provenance: "+ + "it was built by its caller rather than obtained from a producer. "+ + "The only two producers are %s and %s. %s", + e.Half, e.AuditID, producerHalfSealOfRun, producerSealer, sealProvenanceAdvice) + case SealProvenanceTampered: + return fmt.Sprintf( + "record: the %s half seal offered for audit %q was minted by %s and then EDITED: "+ + "it now reads %s where it was minted as %s. A HalfSeal may be copied and passed "+ + "around; assigning to its exported fields turns a real seal into a fabricated one. %s", + e.Half, e.AuditID, e.Producer, e.Live, e.Minted, sealProvenanceAdvice) + case SealProvenanceOriginGone: + return fmt.Sprintf( + "record: the %s half seal offered for audit %q was minted by %s (version %d), and the "+ + "audit it was minted from has since been forgotten; there is nothing left to check it "+ + "against. %s", + e.Half, e.AuditID, e.Producer, e.Version, sealProvenanceAdvice) + default: + return fmt.Sprintf( + "record: the %s half seal offered for audit %q is STALE: it was minted by %s at version %d "+ + "as %s, and the live record now reads %s at version %d. The gate will not answer "+ + "truthfully about a seal nobody has refreshed. %s", + e.Half, e.AuditID, e.Producer, e.Version, e.Minted, e.Live, e.LiveVersion, sealProvenanceAdvice) + } +} + +// Unwrap gives every provenance refusal a distinct sentinel: absent +// provenance is ErrSealNotFromProducer, everything else is ErrSealStale. +func (e *SealProvenanceError) Unwrap() error { + if e.Fault == SealProvenanceAbsent { + return ErrSealNotFromProducer + } + return ErrSealStale +} + +// sealProvenanceAdvice is the one sentence that tells a refused caller what to +// do instead. It is appended to every provenance refusal because the caller +// that hits one is, by construction, a caller that does not know a seal has an +// origin. +const sealProvenanceAdvice = "Obtain a seal from the live record or the live Sealer immediately " + + "before reading, and do not carry one across a state change." // --------------------------------------------------------------------------- // Half-status classification @@ -260,22 +391,284 @@ func IsReadableHalfStatus(s HalfStatus) bool { return s == HalfStatusSealed } // The last two carry negative controls that re-introduce the historical // defects on every run, because a guard that has never been seen to fail has // not been tested. This package has now paid for that lesson three times. +// +// A FOURTH THING WATCHES IT, AND IT IS NOT A TEST. All three guards above ask +// whether the gate was CALLED. None of them can ask whether it was called +// about a seal worth believing — that is dataflow, and readpath_test.go's +// KNOWN LIMITS recorded it as open (adversary attack 14) until CRITIQUE O.4 +// found it happening by accident in a consumer. So the gate now checks its own +// input: HalfSeal carries unexported provenance, and HalfReadGate refuses any +// seal that no producer minted or that its origin has since moved past. That +// is shipped code rather than a test, so it covers callers in other packages +// and callers written after this comment. See "SEAL PROVENANCE" below. + +// --------------------------------------------------------------------------- +// SEAL PROVENANCE — adversary attack 14, closed at runtime +// --------------------------------------------------------------------------- +// +// # The attack +// +// readpath_test.go's KNOWN LIMITS, LIMIT 1: call the gate, check the error, +// obey it — but hand the gate a HalfSeal you built yourself rather than the +// seal of the half you are about to read. Every structural property the source +// guard checks is satisfied. The gate was asked about a half that does not +// exist and it answered honestly. +// +// It was ruled out of scope for STATIC analysis, correctly: which value flowed +// into which parameter is a dataflow question, not a reachability one. The +// documented alternative was a runtime assertion, and this is it. +// +// # The mechanism is the compiler, not a convention +// +// HalfSeal carries an UNEXPORTED pointer, prov. Only this package can set it, +// and inside this package only TWO functions do: +// +// halfSealOfRun the record-side projection over an assembled *SARIFLog +// audit.halfSeal the Sealer's own projection over a live in-flight audit +// +// A composite literal in any other package cannot set an unexported field — +// that is a compile error, not a lint — so no seal built outside internal/record +// can carry provenance, and HalfReadGate refuses a seal that does not. +// +// # Provenance is a STALENESS check, not merely a construction check +// +// A seal minted legitimately ten minutes ago and held across a state change is +// the defect that actually occurred (CRITIQUE O.4 on internal/scanctl: a +// HalfSeal built from caller-held fields with no refresh path). Recording +// "a producer made this" would not have caught it. So prov holds a LIVE HANDLE +// on the object the seal was minted from, plus the facts as they read at +// minting, and the gate RE-READS the origin every time it is asked: +// +// minted the (half, status, auditState, sealedAt) at mint time +// origin the live *SARIFLog+*Run, or the live *audit +// version the origin's revision at mint time, where it has one +// +// Four refusals come out of that, all of them ErrSealStale except the first: +// +// absent no producer minted it (ErrSealNotFromProducer) +// tampered minted, then an exported field was assigned to +// origin_gone the audit was forgotten; nothing left to check against +// stale the origin has moved on since minting +// +// # What this does NOT close +// +// Attack 15 — obey the gate for one half, return both halves' results — uses a +// GENUINE seal. Provenance does not narrow it, and readpath_test.go's KNOWN +// LIMITS still records it as open. See that section for the honest accounting. + +// producer names, spelled once, because every provenance refusal must name +// both of them: a caller that built its own seal is a caller that does not +// know where a real one comes from. +const ( + producerHalfSealOfRun = "record.halfSealOfRun (the record-side projection over an assembled *SARIFLog, " + + "used by readpath.go, taskcard.go and sarif_github.go)" + producerSealer = "record.Sealer (ReadHalf, Inspect, ReadyForConsumption — the live in-flight audit)" +) + +// sealFacts is the readability-relevant content of one half seal at one +// instant. +// +// It is COMPARABLE ON PURPOSE. Currency is decided by `==` over the whole +// struct rather than by a hand-written field-by-field comparison, so a field +// added here is a field the staleness check cannot forget. SealedAt is carried +// as Unix nanoseconds for exactly that reason: a *time.Time would make the +// struct compare by pointer identity and quietly compare nothing. +type sealFacts struct { + half Half + status HalfStatus + auditState State + sealedAtSet bool + sealedAt int64 // UnixNano; meaningful only when sealedAtSet +} + +func (f sealFacts) String() string { + s := fmt.Sprintf("half=%s status=%s state=%s", f.half, f.status, f.auditState) + if f.sealedAtSet { + return s + " sealedAt=" + time.Unix(0, f.sealedAt).UTC().Format(time.RFC3339Nano) + } + return s + " sealedAt=null" +} + +// factsOfSeal reads the facts off a HalfSeal's EXPORTED fields. It is how the +// gate detects a seal that was minted and then edited. +func factsOfSeal(h HalfSeal) sealFacts { + f := sealFacts{half: h.Half, status: h.Status, auditState: h.AuditState} + if h.SealedAt != nil { + f.sealedAtSet = true + f.sealedAt = h.SealedAt.UnixNano() + } + return f +} + +// sealOrigin is the LIVE object a provenanced seal was minted from. The gate +// re-reads it at read time; that is what makes provenance a staleness check +// and not merely a construction check. +type sealOrigin interface { + // liveFacts re-reads the origin NOW. ok is false when the origin can no + // longer answer at all. + liveFacts() (facts sealFacts, version uint64, ok bool) + + // producerName is the legitimate producer that minted the seal. + producerName() string +} + +// sealProvenance is the unforgeable marker. Its zero value is unreachable: a +// HalfSeal either has a non-nil *sealProvenance stamped by a producer, or it +// has nil and the gate refuses it. +type sealProvenance struct { + origin sealOrigin + minted sealFacts + version uint64 // the origin's revision at minting; 0 where it has none +} + +// runOrigin is the record-side producer's live handle: one run of an assembled +// record, plus the envelope that carries the audit-level state. +// +// It has NO revision substrate — a *SARIFLog is a plain value with no mutation +// counter — so currency here is content equality alone. That is weaker than +// the Sealer's arm in one specific way, written down under KNOWN LIMITS: a +// record mutated back to the facts it had at minting reads as current. Half +// statuses and audit states only move forwards, so that requires undoing a +// seal, which nothing in this package does. +// +// It is also NOT safe against a record being mutated concurrently in another +// goroutine — it reads the log and run as plain memory. That matches the rest +// of the read path, which takes an assembled record and treats it as immutable +// for the duration; the Sealer, which IS shared across the SAST worker, the +// DAST worker and the consumer, uses the atomic arm below instead. +type runOrigin struct { + log *SARIFLog + run *Run +} + +func (o runOrigin) liveFacts() (sealFacts, uint64, bool) { + if o.run == nil { + return sealFacts{}, 0, false + } + return factsOfSeal(projectRun(o.log, o.run)), 0, true +} + +func (runOrigin) producerName() string { return producerHalfSealOfRun } + +// auditOrigin is the Sealer's live handle on one half of one in-flight audit. +// It reads through an atomic snapshot rather than the audit's fields, so the +// gate can consult it without taking Sealer.mu — which ReadHalf already holds +// when it calls the gate. +type auditOrigin struct { + a *audit + half Half +} + +func (o auditOrigin) liveFacts() (sealFacts, uint64, bool) { + if o.a == nil { + return sealFacts{}, 0, false + } + live := o.a.live.Load() + if live == nil { + // Sealer.Forget dropped the audit. A seal handed out before that + // point has nothing left to be checked against, and an unverifiable + // seal is refused like any other. + return sealFacts{}, 0, false + } + if o.half == HalfDast { + return live.dast, live.rev, true + } + return live.sast, live.rev, true +} + +func (auditOrigin) producerName() string { return producerSealer } + +// sealProvenanceRefusal is the provenance arm of the gate: it returns the +// typed fault when h must not be believed, and nil when h is a live seal from +// a real producer. +// +// It is deliberately NOT a readability decision — it never asks whether the +// half is sealed or the audit expired — so it composes with the two arms +// rather than becoming a third one. +func sealProvenanceRefusal(h HalfSeal) *SealProvenanceError { + if h.prov == nil { + return &SealProvenanceError{Half: h.Half, Fault: SealProvenanceAbsent} + } + if got := factsOfSeal(h); got != h.prov.minted { + return &SealProvenanceError{ + Half: h.Half, Fault: SealProvenanceTampered, + Producer: h.prov.origin.producerName(), Version: h.prov.version, + Minted: h.prov.minted.String(), Live: got.String(), + } + } + live, liveVersion, ok := h.prov.origin.liveFacts() + if !ok { + return &SealProvenanceError{ + Half: h.Half, Fault: SealProvenanceOriginGone, + Producer: h.prov.origin.producerName(), Version: h.prov.version, + Minted: h.prov.minted.String(), + } + } + if live != h.prov.minted || liveVersion != h.prov.version { + return &SealProvenanceError{ + Half: h.Half, Fault: SealProvenanceStale, + Producer: h.prov.origin.producerName(), + Version: h.prov.version, LiveVersion: liveVersion, + Minted: h.prov.minted.String(), Live: live.String(), + } + } + return nil +} + +// readRefusal is what the one gate body returns: the human reason, and the +// distinct typed cause when the refusal was not one of the two historical +// arms. +// +// The two arms carry a nil cause and keep matching ErrHalfNotSealed alone, +// so every caller written before provenance existed still branches correctly. +type readRefusal struct { + reason string + cause error // *SealProvenanceError, or nil for the two arms +} + +func (r readRefusal) refused() bool { return r.reason != "" } // halfReadRefusal returns the reason a consumer may NOT read this half's -// results, or "" when the gate is open. It is THE definition of readability -// and the only place the two arms are combined. +// results, or the zero readRefusal when the gate is open. It is THE definition +// of readability and the only place the arms are combined. +// +// PROVENANCE IS CHECKED FIRST, before either arm, because the arms are read +// off the seal's own fields: asking whether a value is expired or sealed is +// meaningless until it is established that the value describes a real half of +// a real record as it stands NOW. A fabricated seal that says `sealed` is not +// a sealed half, and a genuine seal held across an expiry is not a live one. // -// The expiry arm is checked FIRST so that an expired audit holding a cleanly -// sealed half reports the expiry — the fact the caller can act on — rather -// than a status that is, on its own, fine. -func halfReadRefusal(h HalfSeal) string { +// Of the two historical arms, the expiry arm is checked FIRST so that an +// expired audit holding a cleanly sealed half reports the expiry — the fact +// the caller can act on — rather than a status that is, on its own, fine. +func halfReadRefusal(h HalfSeal) readRefusal { + if pe := sealProvenanceRefusal(h); pe != nil { + return readRefusal{reason: pe.shortReason(), cause: pe} + } if h.AuditState == StateExpired { - return "the claim timeout elapsed and the payload was dropped" + return readRefusal{reason: "the claim timeout elapsed and the payload was dropped"} } if !IsReadableHalfStatus(h.Status) { - return "this half has no readable results" + return readRefusal{reason: "this half has no readable results"} + } + return readRefusal{} +} + +// shortReason is the one-clause form that goes into ReadGateError.Reason and +// into a manifest's ReadRefusal. The full sentence, with the producer names and +// the mint-versus-live facts, is on the SealProvenanceError itself. +func (e *SealProvenanceError) shortReason() string { + switch e.Fault { + case SealProvenanceAbsent: + return "this seal was not obtained from a producer (" + producerHalfSealOfRun + + " or " + producerSealer + "), so the gate will not answer about it" + case SealProvenanceTampered: + return "this seal was edited after it was minted, so it no longer describes the half it came from" + case SealProvenanceOriginGone: + return "the audit this seal was minted from has been forgotten, so the seal cannot be checked" + default: + return "this seal is stale: the record has moved on since it was minted" } - return "" } // HalfReadGate is the ONE read gate. It returns nil when a consumer may read @@ -291,14 +684,25 @@ func halfReadRefusal(h HalfSeal) string { // input the gate needs — status and audit state — and because reusing it means // there is no second vocabulary for "a half's readiness". A record-side caller // builds one with halfSealOfRun. +// +// A SEAL THE CALLER BUILT ITSELF IS REFUSED. The refusal is a *ReadGateError +// whose Cause is a *SealProvenanceError, matching both errors.Is(err, +// ErrHalfNotSealed) and errors.Is(err, ErrSealNotFromProducer), and naming the +// two producers a real seal comes from. Refusing is the safe direction: a +// caller that built its own seal is exactly the caller whose seal must not be +// trusted. See the seal-provenance section above. func HalfReadGate(auditID string, h HalfSeal) error { - reason := halfReadRefusal(h) - if reason == "" { + refusal := halfReadRefusal(h) + if !refusal.refused() { return nil } + var pe *SealProvenanceError + if errors.As(refusal.cause, &pe) { + pe.AuditID = auditID + } return &ReadGateError{ AuditID: auditID, Half: h.Half, Status: h.Status, State: h.AuditState, - Reason: reason, + Reason: refusal.reason, Cause: refusal.cause, } } @@ -311,11 +715,34 @@ func HalfReadGate(auditID string, h HalfSeal) error { // the mistake CRITIQUE-03 M1 records: `run.Properties.Status` is right there // and `l.Properties.State` is one dereference further away, so three of four // call sites reached for the near one and stopped. +// It is ALSO one of the two legitimate producers of seal provenance: the value +// it returns carries a live handle on (l, run), so a consumer that keeps this +// seal and reads with it after the record has moved on is refused as stale +// rather than answered truthfully about a snapshot. See the seal-provenance +// section above. func halfSealOfRun(l *SARIFLog, run *Run) HalfSeal { + seal := projectRun(l, run) + seal.prov = &sealProvenance{ + origin: runOrigin{log: l, run: run}, + minted: factsOfSeal(seal), + } + return seal +} + +// projectRun is halfSealOfRun's projection WITHOUT the provenance stamp. It is +// separate for one reason: runOrigin.liveFacts must re-read the origin through +// the identical projection, so that "current" and "minted" can never be +// computed two slightly different ways. It is unexported and must stay that +// way — an exported spelling of it would be a seal-shaped value with no +// provenance, i.e. attack 14 reopened with the package's own help. +func projectRun(l *SARIFLog, run *Run) HalfSeal { var state State if l != nil { state = l.Properties.State } + if run == nil { + return HalfSeal{AuditState: state} + } return HalfSeal{ Half: run.Properties.Half, Status: run.Properties.Status, @@ -364,6 +791,23 @@ type HalfSeal struct { // means "no audit context", and only an audit context can withdraw // readability. AuditState State + + // prov is the UNEXPORTED provenance marker, and it is the whole of the + // runtime answer to adversary attack 14. It is nil on any HalfSeal that a + // producer did not mint — a composite literal, a zero value, a struct + // decoded from JSON — and HalfReadGate refuses every seal for which it is + // nil. No package outside internal/record can set it, and the compiler, + // not a convention, is what enforces that. + // + // It also carries a LIVE handle on the object the seal was minted from, + // so the gate can notice a legitimately-minted seal that has since gone + // stale. See the seal-provenance section above for the four faults and + // for what this does and does not close. + // + // A HalfSeal remains comparable with == and its zero value is still the + // zero value: prov is a pointer, and every path that returns "no seal" + // returns HalfSeal{}, which has none. + prov *sealProvenance } // Readable reports whether a consumer may read this half's results. @@ -372,7 +816,10 @@ type HalfSeal struct { // caller that branches and a caller that reports a typed refusal can never // disagree. TestInspectAgreesWithReadHalfOnEveryState asserts the two never // disagree for any (state, status) pair. -func (h HalfSeal) Readable() bool { return halfReadRefusal(h) == "" } +// It is false for a seal no producer minted, and for a seal whose origin has +// moved on since it was minted — the same provenance rule HalfReadGate +// enforces, for the same reason. +func (h HalfSeal) Readable() bool { return !halfReadRefusal(h).refused() } // DastOutcome is what the DAST half (or its absence) reports, and the sole // input from which the audit-level DastStatus is derived. @@ -634,6 +1081,163 @@ type audit struct { dastOutcome DastOutcome dastDerived DastStatus + + // live is the audit's readability-relevant facts, republished on every + // mutation and readable WITHOUT Sealer.mu. It is the substrate every + // Sealer-minted HalfSeal is checked against, and it is atomic rather than + // mutex-guarded because the gate is called from inside ReadHalf, which + // already holds the mutex; a second acquisition would deadlock. + // + // A nil pointer means "this audit can no longer answer" — Forget stores + // nil — and every seal minted from it is then refused. + live atomic.Pointer[auditFacts] +} + +// auditFacts is one published version of an audit's half seals. rev is the +// VERSION the provenance records: it increments only when the facts actually +// change, so re-minting an unchanged seal stays current while a seal held +// across a real transition does not. +type auditFacts struct { + rev uint64 + sast sealFacts + dast sealFacts +} + +// factsFor requires Sealer.mu. +func (a *audit) factsFor(half Half) sealFacts { + status, sealedAt := a.sastStatus, a.sastSealedAt + if half == HalfDast { + status, sealedAt = a.dastStatus, a.dastSealedAt + } + f := sealFacts{half: half, status: status, auditState: a.state} + if sealedAt != nil { + f.sealedAtSet = true + f.sealedAt = sealedAt.UnixNano() + } + return f +} + +// publish republishes the audit's live facts and returns them. It requires +// Sealer.mu. +// +// It is called on every MUTATION — that is what makes a held seal go stale — +// and ALSO on every MINT. The second call looks redundant and is not: a mint +// that publishes what it is about to hand out cannot disagree with the +// substrate the gate will later check it against, so a future mutation path +// that forgets to publish costs a missed staleness detection rather than a +// seal that is stale from birth and reads as current. +// +// The revision advances only on a real change, so Inspect called twice with +// nothing happening in between yields two seals that are both current. +func (a *audit) publish() *auditFacts { + next := &auditFacts{rev: 1, sast: a.factsFor(HalfSast), dast: a.factsFor(HalfDast)} + if cur := a.live.Load(); cur != nil { + if cur.sast == next.sast && cur.dast == next.dast { + return cur + } + next.rev = cur.rev + 1 + } + a.live.Store(next) + return next +} + +// --------------------------------------------------------------------------- +// THE MUTATION FUNNEL — a fact-bearing write and its publish are ONE operation +// --------------------------------------------------------------------------- +// +// The staleness half of seal provenance rests on exactly one invariant: +// +// every mutation of a fact-bearing field of an in-flight audit publishes. +// +// If a mutation skips the publish, a seal minted BEFORE it still equals the +// published facts, so the gate answers CURRENT about a record that has moved. +// That is not a weakened check. It is the check INVERTED, on the precise path +// it was built to defend, and it fails OPEN — the direction where a consumer +// reads a half it may not read and nothing says a word. +// +// The re-verification of R.6 found two mutations that could skip it, both the +// same shape: assign the half's fields, then run a FALLIBLE derivation, then +// return its error before ever reaching the publish. They were +// Sealer.SealHalf's DAST branch and Sealer.SealDastIfDeadlineDue. Neither was +// reachable, because DeriveDastStatus cannot fail on inputs the Sealer's own +// validators admit — but "not reachable" is a statement about TODAY'S CALLERS, +// and callers are the thing that changes. This package has now paid four +// separate times for fixing instances of a class (four read-gate bypasses) +// instead of the class. +// +// So the class is closed twice over, in two independent ways: +// +// 1. STRUCTURALLY. The three functions below are the ONLY places a +// fact-bearing field of an audit is assigned after construction, and each +// one ends in a.publish(). A mutation without a publish cannot be written +// because there is nowhere to write it. +// +// 2. ATOMICALLY. Every fallible step — there is one, the anvil/dastStatus +// derivation — now runs BEFORE the funnel is entered, and its result is +// handed in. A failure therefore leaves the audit completely untouched +// rather than half-moved, so the window in which the fields and the +// published facts could disagree does not exist even for an instant. +// +// TestEveryFactBearingAssignmentGoesThroughTheMutationFunnel (provenance_test.go) +// walks this package's own AST and fails if a fourth mutator appears, if a +// fact-bearing field is assigned anywhere else, or if one of these three loses +// its publish. It is a GUARD, not a proof; its KNOWN LIMITS are written down +// beside it and should be read before a green run is trusted. +// +// CONSTRUCTION IS EXEMPT, deliberately and narrowly: BeginAudit fills a fresh +// &audit{} composite literal while the value is still unreachable from the +// Sealer, so no seal can have been minted from it and there is nothing that +// could go stale. Its first publish happens at the end of BeginAudit, through +// this funnel like every other. + +// setHalf assigns one half's status and sealedAt, re-derives the audit-level +// anvil/state from the two half statuses, and PUBLISHES. It requires Sealer.mu. +// +// dastDerived is a PARAMETER rather than something computed here, and that is +// the atomicity half of the fix: computing it can fail, and a fallible step +// inside the funnel would re-create the very window the funnel exists to +// remove. Callers derive first and pass the result in. For the SAST half they +// pass the audit's existing value unchanged, because the SAST half is not an +// input to anvil/dastStatus. +// +// A half that is not HalfDast is treated as the SAST half, matching +// audit.halfSeal's normalisation, so the two cannot disagree about which half +// an unexpected token names. +func (a *audit) setHalf(half Half, status HalfStatus, sealedAt *time.Time, dastDerived DastStatus) { + if half == HalfDast { + a.dastStatus = status + a.dastSealedAt = sealedAt + } else { + a.sastStatus = status + a.sastSealedAt = sealedAt + } + a.dastDerived = dastDerived + a.state = DeriveState(a.sastStatus, a.dastStatus) + a.publish() +} + +// setLifecycleState assigns an audit-level state that is NOT a function of the +// two halves — StateConsumed and StateExpired, which DeriveState never produces +// because they are explicit transitions — and PUBLISHES. It requires Sealer.mu. +func (a *audit) setLifecycleState(state State) { + a.state = state + a.publish() +} + +// setDastOutcome stores the target-lifecycle facts anvil/dastStatus is derived +// from, together with the value already derived from them, and PUBLISHES. It +// requires Sealer.mu. +// +// Neither field reaches sealFacts today, so this publish is a no-op in +// practice: publish() returns the current version untouched when nothing +// readability-relevant moved. It is called anyway. "This mutator does not need +// to publish" is exactly the reasoning that produced the two paths this funnel +// closes, and the cost of being wrong about it is a silently inverted gate +// against a cost of one struct comparison. +func (a *audit) setDastOutcome(o DastOutcome, dastDerived DastStatus) { + a.dastOutcome = o + a.dastDerived = dastDerived + a.publish() } // Sealer tracks per-half sealing for in-flight audits. The zero value is not @@ -721,6 +1325,20 @@ func (s *Sealer) BeginAudit(cfg AuditConfig) (AuditSeal, error) { } } + // The DAST half's starting status is decided HERE, in the composite + // literal, rather than assigned afterwards. When the DAST tier is not + // installed the half is terminally skipped and never sealed, so SealedAt + // stays nil and the read gate stays shut — see plan/00-SPINE.md S9-AMENDED. + // + // It is a literal and not an assignment because assignment to a + // fact-bearing field belongs to the mutation funnel above, and construction + // is the one exemption: this value is not reachable from the Sealer yet, so + // no seal exists that could be made stale by it. + dastStatus := HalfStatusRunning + if !cfg.DastEnabled { + dastStatus = HalfStatusSkipped + } + a := &audit{ id: cfg.AuditID, startedAt: cfg.StartedAt, @@ -729,7 +1347,7 @@ func (s *Sealer) BeginAudit(cfg AuditConfig) (AuditSeal, error) { dastDeadlineSeconds: copyInt(cfg.DastDeadlineSeconds), dastEnabled: cfg.DastEnabled, sastStatus: HalfStatusRunning, - dastStatus: HalfStatusRunning, + dastStatus: dastStatus, // Default provenance until the target lifecycle harness reports one. // no_target_declared derives skipped_no_manifest, so an audit whose // DAST half seals without anyone calling RecordDastOutcome can never @@ -740,13 +1358,8 @@ func (s *Sealer) BeginAudit(cfg AuditConfig) (AuditSeal, error) { }, } - if !cfg.DastEnabled { - // The DAST tier is not installed: terminally skipped, never sealed, - // so SealedAt stays nil and the read gate stays shut. - a.dastStatus = HalfStatusSkipped - a.dastSealedAt = nil - } - + // Derive BEFORE the funnel, so a derivation failure aborts BeginAudit with + // nothing registered and nothing published. derived, err := DeriveDastStatus(a.dastStatus, a.dastOutcome) if err != nil { return AuditSeal{}, &SealingError{ @@ -754,8 +1367,10 @@ func (s *Sealer) BeginAudit(cfg AuditConfig) (AuditSeal, error) { Reason: "cannot derive anvil/dastStatus: " + err.Error(), Err: err, } } - a.dastDerived = derived - a.state = DeriveState(a.sastStatus, a.dastStatus) + // The audit's first publish, through the same funnel every later mutation + // uses. It restates the DAST half's constructed status, which is what makes + // the initial published facts and the fields provably equal. + a.setHalf(HalfDast, a.dastStatus, a.dastSealedAt, derived) s.audits[cfg.AuditID] = a return a.snapshot(), nil @@ -802,16 +1417,16 @@ func (s *Sealer) RecordDastOutcome(auditID string, o DastOutcome) error { } else { o.Provenance = TargetProvenanceNoTargetDeclared } - a.dastOutcome = o - - derived, err := DeriveDastStatus(a.dastStatus, a.dastOutcome) + // Derive from the CANDIDATE outcome before storing anything, so a + // derivation failure leaves the audit exactly as it was. + derived, err := DeriveDastStatus(a.dastStatus, o) if err != nil { return &SealingError{ Op: "RecordDastOutcome", AuditID: auditID, Half: HalfDast, State: a.state, Reason: "cannot derive anvil/dastStatus: " + err.Error(), Err: err, } } - a.dastDerived = derived + a.setDastOutcome(o, derived) return nil } @@ -883,23 +1498,28 @@ func (s *Sealer) SealHalf(auditID string, half Half, status HalfStatus) error { sealedAt = &t } - if half == HalfSast { - a.sastStatus = status - a.sastSealedAt = sealedAt - } else { - a.dastStatus = status - a.dastSealedAt = sealedAt - derived, derr := DeriveDastStatus(a.dastStatus, a.dastOutcome) + // DERIVE BEFORE MUTATING. This branch used to assign a.dastStatus and + // a.dastSealedAt and only then call the fallible derivation, returning its + // error from between the assignment and the publish. That left the half + // moved, the published facts behind, and every seal minted before the move + // still reading as CURRENT — the read gate's staleness arm inverted on the + // one path it exists to defend. It was not reachable, because + // DeriveDastStatus cannot fail on inputs this file's own validators admit; + // the shape is fixed anyway, because reachability is a property of the + // callers and the callers change. See THE MUTATION FUNNEL above. + dastDerived := a.dastDerived + if half == HalfDast { + derived, derr := DeriveDastStatus(status, a.dastOutcome) if derr != nil { return &SealingError{ Op: "SealHalf", AuditID: auditID, Half: half, State: a.state, Status: status, Reason: "cannot derive anvil/dastStatus: " + derr.Error(), Err: derr, } } - a.dastDerived = derived + dastDerived = derived } - a.state = DeriveState(a.sastStatus, a.dastStatus) + a.setHalf(half, status, sealedAt, dastDerived) return nil } @@ -988,7 +1608,7 @@ func (s *Sealer) Consume(auditID string) error { Reason: "the claim timeout elapsed", Err: ErrAuditTerminal, } case StateBothSealed: - a.state = StateConsumed + a.setLifecycleState(StateConsumed) return nil default: return &SealingError{ @@ -1023,12 +1643,126 @@ func (s *Sealer) ExpireIfDue(auditID string) (bool, error) { if s.now().Before(a.deadlineAt) { return false, nil } - a.state = StateExpired + a.setLifecycleState(StateExpired) + return true, nil +} + +// --------------------------------------------------------------------------- +// CLOCK 3 — the DAST deadline, and why its due-check lives here +// --------------------------------------------------------------------------- +// +// This file's header names three independent clocks. Two of them already have +// an authoritative substrate in this package: `anvil/sealedAt` is stamped by +// SealHalf and nothing else, and `deadline_at` (clock 2) is computed once in +// BeginAudit and enforced by ExpireIfDue against the Sealer's own private +// copy. +// +// Clock 3 — `dast_deadline_seconds`, the clock that forces a +// never-terminating DAST half terminal — had a substrate here (AuditConfig +// stores it, AuditSeal reports it) and NO due-check. CRITIQUE O.4 blocker 2 +// found the consequence: the only due-check lived in a caller, over a caller- +// OWNED field, and was moved by plain field assignment. Clock 2 shrugged the +// same probe off precisely because the Sealer holds its own copy. +// +// So the due-check goes where the authoritative input already is, which is the +// same place ExpireIfDue lives, for the same reason. It is not a third owner +// of clock 3: it is the substrate the existing derived copies must agree with. +// What a caller keeps is a DERIVED, advisory instant; what decides is the +// audit's own `startedAt + dastDeadlineSeconds`, which nothing after +// BeginAudit can move. + +// ComputeDastDeadline returns `scan_run.started_at + dast_deadline_seconds`, +// the one and only formula for clock 3, and ok=false when the audit has no +// DAST deadline at all (a nil `dast_deadline_seconds`, which is what a +// DAST-disabled audit carries). +// +// It is the exact analogue of ComputeDeadline, and it is deliberately anchored +// to the SCAN START for the same reason: anchoring clock 3 to the last DAST +// write would let a chatty DAST half postpone its own timeout indefinitely, +// which is the one thing this clock exists to prevent. +func ComputeDastDeadline(startedAt time.Time, dastDeadlineSeconds *int) (time.Time, bool) { + if dastDeadlineSeconds == nil || *dastDeadlineSeconds <= 0 { + return time.Time{}, false + } + return startedAt.Add(time.Duration(*dastDeadlineSeconds) * time.Second), true +} + +// DastDeadlineAt is clock 3 for this audit, derived from the snapshot's own +// StartedAt and DastDeadlineSeconds — both of which the Sealer fixed at +// BeginAudit. +// +// A caller that needs to display or compare the DAST deadline should derive it +// here rather than keep its own field: a kept field is a field something can +// assign to, and CRITIQUE O.4 blocker 2 is what that costs. +func (s AuditSeal) DastDeadlineAt() (time.Time, bool) { + return ComputeDastDeadline(s.StartedAt, s.DastDeadlineSeconds) +} + +// SealDastIfDeadlineDue seals the DAST half as HalfStatusTimedOut if and only +// if clock 3 has elapsed and that half is still running, and reports whether it +// did. It is clock 3's ExpireIfDue. +// +// The due-check is against the audit's OWN startedAt and dastDeadlineSeconds, +// which BeginAudit fixed and nothing since can move — not against any instant +// the caller holds. There is deliberately no unconditional ForceDastTimeout, +// for the same reason there is no unconditional Expire. +// +// It returns (false, nil), not an error, for every ordinary reason to do +// nothing: no DAST deadline configured, the deadline not yet reached, the half +// already terminal (including the DAST-disabled audit whose half BeginAudit +// sealed as skipped), or an audit that no longer accepts writes. A tick that +// found nothing to do is not a failure. +// +// It does NOT touch clock 2. Forcing a DAST timeout does not move DeadlineAt by +// one nanosecond, and R.6's forbidden actions are what say so. +func (s *Sealer) SealDastIfDeadlineDue(auditID string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + a, err := s.lookup("SealDastIfDeadlineDue", auditID) + if err != nil { + return false, err + } + // The same write-side question SealHalf asks: a terminal audit accepts no + // seals, forced or otherwise. + if a.state == StateConsumed || a.state == StateExpired { + return false, nil + } + if IsTerminalHalfStatus(a.dastStatus) { + return false, nil + } + due, ok := ComputeDastDeadline(a.startedAt, a.dastDeadlineSeconds) + if !ok || s.now().Before(due) { + return false, nil + } + + // DERIVE BEFORE MUTATING — the second of the two paths described under THE + // MUTATION FUNNEL. It had the identical shape: assign a.dastStatus and + // a.dastSealedAt, then return the derivation's error from between the + // assignment and the publish. + derived, derr := DeriveDastStatus(HalfStatusTimedOut, a.dastOutcome) + if derr != nil { + return false, &SealingError{ + Op: "SealDastIfDeadlineDue", AuditID: auditID, Half: HalfDast, + State: a.state, Status: HalfStatusTimedOut, + Reason: "cannot derive anvil/dastStatus: " + derr.Error(), Err: derr, + } + } + // timed_out is terminal but NOT sealed, so sealedAt stays null. + a.setHalf(HalfDast, HalfStatusTimedOut, nil, derived) return true, nil } // Inspect returns a snapshot of the audit's sealing state, or ok=false if the -// audit is unknown. The snapshot shares no mutable state with the Sealer. +// audit is unknown. Nothing a caller does to the snapshot can reach the +// Sealer: every field is a copy, and the seal provenance it carries is a +// READ-ONLY handle used by the read gate and by nothing else. +// +// The snapshot's VALUES are frozen, but its readability is not: a HalfSeal +// taken from Inspect and kept across a seal, a consumption or an expiry is +// refused as stale when it is finally used. That is deliberate — an +// arbitrarily old snapshot answering "yes, readable" for a live audit is +// CRITIQUE O.4's defect. Re-Inspect at the point of use. // // Inspect is a DIAGNOSTIC: it deliberately still reports the true status of an // expired audit's halves, because "this audit expired holding a sealed SAST @@ -1050,9 +1784,18 @@ func (s *Sealer) Inspect(auditID string) (AuditSeal, bool) { // Forget drops an audit from the in-memory tracker. The durable row in // `audit_record` is unaffected — plan/40-record-and-storage.md is explicit // that the reaper drops the payload and never the row. +// +// Any HalfSeal already handed out for this audit stops being readable at this +// point: its provenance can no longer be checked against anything, and an +// unverifiable seal is refused (SealProvenanceOriginGone). Forgetting an audit +// and then honouring seals minted from it would be the same defect as +// honouring a stale one. func (s *Sealer) Forget(auditID string) { s.mu.Lock() defer s.mu.Unlock() + if a, ok := s.audits[auditID]; ok { + a.live.Store(nil) + } delete(s.audits, auditID) } @@ -1067,21 +1810,36 @@ func (s *Sealer) lookup(op, auditID string) (*audit, error) { return a, nil } -// halfSeal requires Sealer.mu. +// halfSeal requires Sealer.mu. It is the SECOND of the two legitimate +// producers of seal provenance; the other is halfSealOfRun. // // AuditState is stamped here, on every path — ReadHalf's, Inspect's and // snapshot's alike — so there is no way to obtain a HalfSeal from a Sealer // whose Readable() answers a different question from ReadHalf's gate. +// +// The provenance stamped here holds a live handle on this audit and the +// version the facts were published at, so an AuditSeal taken from Inspect and +// kept across a seal, a consumption or an expiry stops being readable at the +// moment the audit moves, rather than continuing to answer for the audit as it +// was. That is the O.4 defect, in this package's own shape. func (a *audit) halfSeal(half Half) HalfSeal { + if half != HalfDast { + half = HalfSast + } + live := a.publish() + facts := live.sast + sealedAt := a.sastSealedAt if half == HalfDast { - return HalfSeal{ - Half: HalfDast, Status: a.dastStatus, - SealedAt: copyTime(a.dastSealedAt), AuditState: a.state, - } + facts, sealedAt = live.dast, a.dastSealedAt } return HalfSeal{ - Half: HalfSast, Status: a.sastStatus, - SealedAt: copyTime(a.sastSealedAt), AuditState: a.state, + Half: half, Status: facts.status, + SealedAt: copyTime(sealedAt), AuditState: facts.auditState, + prov: &sealProvenance{ + origin: auditOrigin{a: a, half: half}, + minted: facts, + version: live.rev, + }, } } @@ -1168,5 +1926,11 @@ func Consume(auditID string) error { return defaultSealer.Consume(auditID) } // Sealer.ExpireIfDue. func ExpireIfDue(auditID string) (bool, error) { return defaultSealer.ExpireIfDue(auditID) } +// SealDastIfDeadlineDue applies clock 3 on the default Sealer. See +// Sealer.SealDastIfDeadlineDue. +func SealDastIfDeadlineDue(auditID string) (bool, error) { + return defaultSealer.SealDastIfDeadlineDue(auditID) +} + // Inspect snapshots an audit on the default Sealer. See Sealer.Inspect. func Inspect(auditID string) (AuditSeal, bool) { return defaultSealer.Inspect(auditID) } diff --git a/internal/record/sealing_test.go b/internal/record/sealing_test.go index 68aaf41..83e5457 100644 --- a/internal/record/sealing_test.go +++ b/internal/record/sealing_test.go @@ -1295,7 +1295,19 @@ func TestInspectAgreesWithReadHalfOnEveryState(t *testing.T) { func TestEverySpellingOfTheReadGateAgrees(t *testing.T) { for _, state := range StateValues() { for _, status := range HalfStatusValues() { - seal := HalfSeal{Half: HalfSast, Status: status, AuditState: state} + // The seal is OBTAINED FROM A PRODUCER — halfSealOfRun over a + // real one-run record — and not built here. It used to be + // `HalfSeal{Half: HalfSast, Status: status, AuditState: state}`, + // which is adversary attack 14's exact shape: a seal handed to + // the gate that no producer minted. Seal provenance now refuses + // those, so a hand-built literal would have made every one of the + // assertions below compare two provenance refusals and prove + // nothing about either arm of the gate. + l := &SARIFLog{ + Properties: AuditProperties{AuditID: "audit-1", State: state}, + Runs: []Run{{Properties: RunProperties{Half: HalfSast, Status: status}}}, + } + seal := halfSealOfRun(l, &l.Runs[0]) // (1) The bool spelling, which is what a caller branches on. readable := seal.Readable() @@ -1328,11 +1340,9 @@ func TestEverySpellingOfTheReadGateAgrees(t *testing.T) { // (3) The record-side spelling, built from a run and its envelope. // This is the projection readpath.go, taskcard.go and // sarif_github.go all go through, and the one CRITIQUE-03 found - // two callers reaching around. - l := &SARIFLog{ - Properties: AuditProperties{AuditID: "audit-1", State: state}, - Runs: []Run{{Properties: RunProperties{Half: HalfSast, Status: status}}}, - } + // two callers reaching around. Re-projected rather than reusing + // `seal`, so that two separately-minted seals over the same + // record still agree. if got := halfSealOfRun(l, &l.Runs[0]).Readable(); got != readable { t.Errorf("state=%q status=%q: halfSealOfRun(...).Readable() = %t, want %t", state, status, got, readable) @@ -1422,6 +1432,10 @@ func gateArmAllowlist() map[string]string { "consumer may read; the read direction is ReadHalf, which calls the gate.", "sealing.go:Sealer.SealHalf:StateExpired": "refuses a seal on a terminal audit — the " + "same write-side question as RecordDastOutcome.", + "sealing.go:Sealer.SealDastIfDeadlineDue:StateExpired": "refuses to force clock 3's " + + "DAST timeout on a terminal audit — the same write-side question as SealHalf. It " + + "decides whether the SEALER accepts a WRITE, not whether a consumer may read; a " + + "forced timeout is a seal, and seals stop at a terminal audit.", "sealing.go:Sealer.Consume:StateExpired": "refuses consumption of an expired audit. " + "Consumption is a state transition on the audit, not a read of a half.", "sealing.go:Sealer.ExpireIfDue:StateExpired": "the expiry transition itself: already " + diff --git a/internal/scanctl/REVIEW-O.4.md b/internal/scanctl/REVIEW-O.4.md new file mode 100644 index 0000000..282019e --- /dev/null +++ b/internal/scanctl/REVIEW-O.4.md @@ -0,0 +1,425 @@ +# REVIEW-O.4 — critique of the scan controller core (O.1 deadlines, O.2 state machine, O.3 handoff adapter) and O.5/O.6/O.7 policy + +**Verdict: FAIL — 2 blockers, 5 majors, 6 minors.** + +**This was a SAME-FAMILY critic.** O.4's packet routes this step to OpenCode `openai/gpt-5.5`; that +route is WITHDRAWN by the OWNER DECISION block at the top of `plan/00-ROUTING.md` (2026-08-07, +external routes copy private project files to a third party). The cross-family guarantee O.4 was +written to obtain **was not obtained and is still owed**. A later reader must not record this file as +"cross-family critic: PASS". The compensation applied was method, not model: every claim in the +reviewed files was re-checked against the source, and every behavioural finding below is backed by a +probe I wrote and ran myself rather than by reading. Reported output was treated as unevidenced +throughout. + +--- + +## 1. Method + +- Read in full: `internal/scanctl/deadlines.go` (730 lines), `statemachine.go` (1061), + `handoff.go` (701), plus `statemachine_test.go` and `handoff_test.go`; and, as the frozen + substrate they claim to consume, `internal/record/{contract.go,sealing.go}` and + `internal/handoff/{claim.go,reaper.go,state_machine.go}`. +- Also reviewed, per the dispatch: `internal/policy/{locate.go,engine.go,semver.go}` (O.5/O.6/O.7) + and `schemas/policy.schema.json`. +- Probes were compiled into the packages under review via `go test -overlay=…`, so **no file was + added to or modified in the repository** by this review other than this one. `git status --short` + before and after is unchanged (`?? internal/policy/`, `?? internal/scanctl/`, + `?? schemas/policy.schema.json`). +- Every probe named below is reproducible: the probe sources are transient, but each finding states + the exact construction, and each was run with `-count=1`. + +### The four required gates, run by me, real output + +``` +$ go version +go version go1.26.5 windows/amd64 + +$ gofmt -l . +(no output) + +$ go build ./... +OK + +$ go vet ./... +OK + +$ go test -count=1 ./... +ok github.com/Susquehanna-Syntax/Anvil/cmd/anvil 0.392s +? github.com/Susquehanna-Syntax/Anvil/cmd/anvil-dast [no test files] +? github.com/Susquehanna-Syntax/Anvil/internal/buildpin [no test files] +ok github.com/Susquehanna-Syntax/Anvil/internal/handoff 1.162s +ok github.com/Susquehanna-Syntax/Anvil/internal/policy 7.605s +ok github.com/Susquehanna-Syntax/Anvil/internal/record 1.261s +ok github.com/Susquehanna-Syntax/Anvil/internal/scanctl 1.340s +ok github.com/Susquehanna-Syntax/Anvil/internal/store 0.302s + +$ go test -count=1 -race ./internal/scanctl/ +runtime/cgo: C:\Program Files\Go\pkg\tool\windows_amd64\cgo.exe: exit status 2 +FAIL github.com/Susquehanna-Syntax/Anvil/internal/scanctl [build failed] +``` + +**The suite is green and the findings below are all in territory the suite does not enter.** That is +the reason a green suite is not evidence here: three of the seven confirmed defects are reached only +by holding an `AuditRecord` across a state change, and every test in the package constructs a fresh +one and immediately consumes it. + +--- + +## 2. What I tried to break and could not — stated so a later reader knows what was actually checked + +These are not concessions; each was attacked with a specific probe or a mechanical scan, and each +held. + +| Claim | How I attacked it | Result | +|---|---|---| +| **No second state machine (ruling G2).** | Grepped the whole package for `DeriveState`, a transition table, a seal ordering, or any assignment to `State`/`Status`/`SealedAt`/`DastStatus` outside `project`. | **HOLDS.** `project()` (statemachine.go:1023) is the only writer of all five lifecycle fields, and its only input is a `record.AuditSeal`. The three occurrences of the string `DeriveState` in the package are all in comments. `EventKind`'s six literals are checked against every frozen enum by `TestEventKindsDoNotCollideWithFrozenEnums` and none collides. | +| **No second handoff/lease API, no second migration (ruling G9).** | Grepped for `CREATE TABLE`, `ALTER TABLE`, `migration`, `database/sql`, `internal/store`, and SQL keywords across all three files. | **HOLDS.** Zero hits outside comments. `handoff.go` re-exports nothing it does not delegate, `handoff.ExhaustedState` is referenced rather than re-picked (handoff.go:638), and `Queue()` is an honest escape hatch rather than a wall. | +| **No bare enum string literal.** | Ran my own regex for all 60-odd frozen literals across the three non-test files, independent of the package's own guard. | **HOLDS.** One hit, and it is prose inside a doc comment (statemachine.go:357, `"started" is not "sealed"`). | +| **No hard-coded trigger policy.** | Grepped `internal/scanctl` for event names, ref patterns, branch names, cron/`OnCalendar` strings and file globs. Read `internal/policy/engine.go` end to end for a decision that cannot be moved by editing `policy.yml`. | **HOLDS, and it is genuinely well done.** `Evaluate` injects no built-in detector, depth or failOn (probe P7: an empty `version: 1` policy resolves to `detectors=[] depth=""`), `Schedule.OnCalendar` is passed through verbatim without parsing, and `searchOrder` is file *locations* with the distinction argued in place (locate.go:55-66). The `dast` warning at engine.go:504 emits text and changes no decision. | +| **Deadlines not recomputable by a late write (clock 2).** | Probe P10, second half: pushed `rec.Deadlines.DeadlineAt` to `t+100h` and ticked at `t+9h`. | **HOLDS.** `record.Sealer.ExpireIfDue` owns clock 2 against its own immutable `deadlineAt`; the audit expired anyway. Clock 3 does **not** hold — see O4-B2. | +| **Claim timeout not treated as a deletion or confidentiality control (S1 #5).** | Read deadlines.go:71-75 and every use of `ClaimTimeout`/`DeadlineAt`. | **HOLDS.** Nothing deletes, nothing is described as retention, and the file says so in its own words rather than by omission. | +| **`settled` / `acceptsWrites` are not read-gate re-derivations.** | Traced every caller. | **HOLDS.** `settled` (statemachine.go:665) feeds only `DurableWriteDue`; `acceptsWrites` (682) feeds only the two write guards. Neither is consulted by `Findings`. `TestAcceptsWritesAgreesWithTheSealer` drives a real Sealer through all six states and compares against `ErrAuditTerminal` — a real test, not a tautology. | +| **Map iteration where order matters.** | Grepped every `range` in the four non-test files of both packages. | **HOLDS.** The only map range in either package is `policy.checkKeys` (engine.go:1115), and it sorts before reporting with the reason written down. `FieldSources` is deliberately a struct and not a map. | +| **Tests that assert nothing.** | Read the source-guard tests and the crash/reclaim test. | **HOLDS.** `TestTheAdapterOpensNoDatabaseAndWritesNoSQL` and `TestTheAdapterUsesNoBareEnumLiteral` both carry negative controls that trip the same predicate. `TestCrashedHolderIsReclaimedAndReprocessedWithoutDoubleApplying` counts *distinct* side effects and asserts both attempts really ran, which is what makes the "exactly one" assertion mean something. No golden is regenerated by its own test. | +| **The four-state machine is gone.** | Checked the vocabulary literal by literal against §6 G2/G5. | **HOLDS.** No `open`, no `complete` anywhere in the package. Transitions key on `record.HalfStatusSealed` and terminality on `record.IsTerminalHalfStatus`. | +| **A stalled GitHub check cannot corrupt or stall the record.** | Checked for any GitHub coupling or fourth clock. | **HOLDS STRUCTURALLY.** `internal/scanctl` imports only `errors`/`fmt`/`time`/`context` plus `internal/record` and `internal/handoff`. There is no publisher, no token, no retry loop, and therefore no path by which a check-run update can block a transition. deadlines.go:235-280 states the residual risk as OPEN and names the invariant. **But:** the isolation is asserted by absence and by prose, and there is no test named for the invariant; and O4-B2 below is precisely the mechanism by which a future publisher handed an `AuditRecord` *could* move a deadline. See §5. | + +--- + +## 3. Blocking findings + +### O4-B1 — BLOCKER. The read gate is evaluated against a snapshot the caller owns, so an expired audit's findings are readable. `statemachine.go:532-580` + +`AuditRecord.HalfSeal` builds the `record.HalfSeal` the gate takes out of **two fields of the caller's +own value** — `h.Status` from `r.Sast`/`r.Dast`, and `AuditState` from `r.State`. `Findings` then +calls `record.HalfReadGate` on it. The gate is called, and neither arm is reimplemented; the header's +claim on that point is true. What is not true is that the answer is the system's answer. The gate is +being asked about a value that stopped tracking the Sealer the moment the caller stopped calling +`Transition`, and **there is no refresh path**: `Controller` exposes `Policy`, `Watermarks`, `Sealer`, +`Begin`, `Transition`, `NextWake` and nothing that re-projects an existing record. + +Probe P3, run against the package: + +``` +=== RUN TestProbeP3StaleRecordDefeatsTheReadGate + record.Sealer.ReadHalf refuses: record: read of sast half of audit "P3" refused: + status is "sealed", state is "expired"; the claim timeout elapsed and the payload + was dropped (the gate opens only at anvil/status="sealed") + PROBE HIT: 1 finding(s) read out of an EXPIRED audit through the stale record; + AuditRecord.Readable()=true, errors.Is(err, ErrHalfNotSealed)=false +``` + +Construction: `Begin` → one SAST finding → seal SAST → keep the returned record (`stale`) → advance +the clock past the 8h claim window → `TickEvent` on a *different* copy → the Sealer is now +`expired` and `ReadHalf` refuses → `stale.Findings(sast)` returns the finding. + +This is CRITIQUE-03 M1's outcome — "an EXPIRED audit was fully readable and handed a coding agent +actionable task cards against a claim window that had already closed" — reached by a new route. It is +not the same bug (that one checked one arm; this one checks both arms of a stale input), which is +exactly why the three guards in `internal/record` cannot see it: `TestReadGateArmsAppearOnlyInsideTheGate` +calls `parser.ParseDir(fset, ".", …)` and scans **only `internal/record`**, and the two behavioural +guards live in `readpath_test.go` and cover that package's entry points. Nothing watches this package. + +`TestReadableAgreesWithFindingsEverywhere` and `TestFindingsAreGatedInEveryUnreadableShape` pass +because `driveToState` (statemachine_test.go:393) always returns the record produced by the last +transition. The suite never holds a record across a state change. + +**Proposed fix (design, not text):** make the result surface a `Controller` method, not an +`AuditRecord` method — `func (c *Controller) Findings(rec AuditRecord, half record.Half) ([]record.Result, error)` +that re-`Inspect`s the Sealer and builds the `HalfSeal` from the live `AuditSeal` before calling +`record.HalfReadGate`. Keep `AuditRecord.Findings` only if it is unexported or documented as +snapshot-scoped, and add a regression test named for this shape. + +### O4-B2 — BLOCKER. Clock 3 has no authoritative substrate: a late write moves the DAST deadline by plain field assignment. `statemachine.go:965`, `deadlines.go:558-564` + +`Deadlines`' contract is unambiguous: *"THE ONLY WAY TO CHANGE A DEADLINE IS TO START A NEW SCAN. No +seal, no write, no publication, no consumer read and no GitHub round-trip moves either field."* The +supporting argument is that `Deadlines` "is a value … and there is no method on it that mutates +anything". That is true of methods and irrelevant to fields: `AuditRecord.Deadlines` is exported, +`clone()` copies it verbatim, and `project()` never touches it. `applyTick` then reads clock 3 out of +that caller-owned field. + +Probe P10: + +``` +=== RUN TestProbeP10ClockThreeIsCallerMutable + sealer holds startedAt=2026-08-07T09:00:00Z dastDeadlineSeconds=14400 (immutable) + record holds DastDeadlineAt=2026-08-07T13:00:00Z + at t+5h with the deadline moved to t+7h59m: dast=running dastStatus=running state=collecting + PROBE HIT: clock 3 was moved by assigning to AuditRecord.Deadlines and the forced seal + did not fire; DAST half is "running". The Sealer still holds the true + dast_deadline_seconds=14400 and AuditSeal exposes it, but project() never re-derives + Deadlines from it. + clock 2 with DeadlineAt pushed to t+100h: state=expired (Sealer.ExpireIfDue is unfooled: true) +``` + +The last line is the point. Clock 2 shrugs the attack off **because the Sealer owns a private copy**. +Clock 3 does not, even though `record.AuditSeal` already carries `StartedAt` and +`DastDeadlineSeconds` (sealing.go, `AuditSeal`) — every input needed to re-derive it is right there in +the value `project()` already receives, and `project()` ignores both. + +Why this is blocking rather than a footgun: clock 3 is the *only* thing that forces a +never-terminating DAST half terminal (Constraint Resolution (c)). Moving it past +`DeadlineAt` is exactly the configuration `DastDeadlineBinds` warns costs "the SAST findings are then +lost to the claim window rather than handed over" — and here it can happen at runtime, from any code +holding the record, with no policy change and no diagnostic. It is also the concrete counterexample +to §5's isolation invariant: a future check-run publisher handed an `AuditRecord` is one assignment +away from moving a deadline. + +**Proposed fix:** re-derive `Deadlines` inside `project()` from `record.AuditSeal.StartedAt` + +`ClaimTimeoutSeconds`/`DastDeadlineSeconds`, so the Sealer is the substrate for both clocks; or +unexport the field behind an accessor. Add a test `TestClockThreeCannotBeMovedByAssignment` mirroring +the second half of P10. + +--- + +## 4. Major findings + +### O4-M1 — MAJOR. A redelivered seal event bumps `audit_version` although the Sealer treated it as a no-op. `statemachine.go:842-848` + +`record.Sealer.SealHalf` is documented idempotent — "Re-sealing a half with the IDENTICAL status is a +no-op and preserves the original SealedAt, so a retried store write cannot move a seal timestamp" — +and returns `nil`. `Transition` cannot tell that `nil` from a real seal and calls `c.publish(&out)` +unconditionally. + +``` +=== RUN TestProbeP1DuplicateSealEventBumpsVersion + after first seal: version=2 state=both_sealed + after second seal: version=3 ; after third: version=4 + PROBE HIT: version 2 -> 3 -> 4 on redelivered seals that changed nothing + (Sealer.SealHalf is documented idempotent and returned nil each time); VersionBumped=true +``` + +The cost is not cosmetic and is paid in two other packages. Every bump obliges S6's queue re-cut +(R.11) — the file says so itself at `VersionBumped`. Worse, `internal/handoff` re-checks +`audit_record.audit_version` on **every** mutation through `checkRecordVersion` (claim.go:641) and +answers `ErrRecordVersionChanged`; `Task.RecordVersion`'s own doc in handoff.go:281-286 says "work +against a stale version is refused rather than applied". So one duplicated worker message — the +ordinary consequence of at-least-once delivery, a retried store write, or two workers fanning the +same seal in — invalidates every in-flight lease on that audit and forces a full re-cut, for a +transition that changed nothing. `TestVersionIsMonotonic` checks monotonicity, which this does not +violate; nothing checks that a bump corresponds to a change. + +**Proposed fix:** have `Transition` compare the `record.AuditSeal` before and after the Sealer call +and publish only on a real difference. `Inspect` is already called at the top of `Transition` (line +823) and its result is discarded — the before-image is free. + +### O4-M2 — MAJOR. The write guards consult the caller's stale `anvil/state`, so findings and correlation land on an expired audit. `statemachine.go:905, 998` + +`applyFindings` and `applyCorrelation` reach no Sealer entry point, so they carry their own +`acceptsWrites(out.State)` guard — and `out.State` is `rec.State`, the caller's copy, not the +Sealer's. The mirror is faithful (M2's own test proves `acceptsWrites` agrees with +`ErrAuditTerminal`); the *input* is not. + +``` +=== RUN TestProbeP2StaleRecordAcceptsWritesAfterExpiry + sealer state = expired ; stale copy state = dast_sealed + PROBE HIT: findings accepted onto an EXPIRED audit via a stale record; + projected state=expired, buffered sast findings=1 + PROBE HIT: correlation accepted onto an EXPIRED audit; state=expired clusters=1 +``` + +`acceptsWrites`' own doc names the failure it is there to prevent — "without this, findings would +keep piling onto an expired audit that record has already given up on" — and it does not prevent it. +Combined with O4-B1, findings buffered after expiry are also *readable* through the same stale record. + +**Proposed fix:** the same one-line change as O4-M1 — keep the `record.AuditSeal` from the `Inspect` +at line 823 and project it onto `out` **before** the switch, so every guard in this file reads the +Sealer's answer. + +### O4-M3 — MAJOR. Concurrent fan-in silently loses findings; the documented failure mode understates it. `statemachine.go:699-706` + +`Controller`'s doc: *"two goroutines transitioning the same audit will each get a consistent record, +but the LAST writer's version counter wins. A caller that fans events in from several workers should +serialise Transition per audit; the Sealer will still refuse an illegal seal either way, so the +failure mode is a skipped version bump, not a corrupt lifecycle."* + +The mitigating instruction is present. The characterisation of the consequence is wrong, and it is +the characterisation an implementer will act on: + +``` +=== RUN TestProbeP11ConcurrentFanInLosesFindings + 8 workers x 3 DAST findings = 24 expected; the surviving record holds 3 + (pendingDast=3 version=1) + PROBE HIT: 21 of 24 findings lost. +``` + +The lifecycle is indeed not corrupted — the Sealer's mutex sees to that. But `findings[]`, +`Correlation`, `Version`, `PublishedAt` and `PendingDastFindings` live on the caller-owned value with +no mutex anywhere, so fan-in loses *results*, on a security scanner, silently. "A skipped version +bump" and "seven eighths of the DAST findings are gone" are not the same warning. + +There are **zero** concurrency tests in `internal/scanctl` (`grep -nE "go func|sync\.|WaitGroup|Parallel"` +over both test files returns nothing), and `-race` cannot run on this host, so CI is the only place +this could ever have surfaced — and CI would not surface it either, because no test creates a second +goroutine. + +**Proposed fix:** either move the findings/correlation buffers into the `Controller` behind the same +lock discipline as the Sealer, or restate the doc as "fan-in loses findings; serialise per audit" and +add `TestConcurrentFanInIsSerialisedPerAudit` as a live probe of whichever choice is made. + +### O4-M4 — MAJOR. Glob matching is super-polynomial and unbounded, and the pattern comes from the scanned repository. `internal/policy/engine.go:706-736` + +`matchSegments` handles `**` by recursing over every split point with no memoisation, so `k` +independent `**` segments against an `n`-segment path costs O(n^k). `validateGlob` bounds nothing but +syntax, `schemas/policy.schema.json` carries no `maxItems`/`maxLength` anywhere (its only `pattern` +is the duration regex on line 50), and `Evaluate` applies no budget. + +``` +=== RUN TestProbeP6GlobBlowup + path segments=20 `**` count= 6 elapsed=2.6212ms + path segments=20 `**` count= 8 elapsed=28.8407ms + path segments=20 `**` count=10 elapsed=285.0843ms + path segments=20 `**` count=11 elapsed=796.8441ms + path segments=30 `**` count= 6 elapsed=19.0663ms + path segments=30 `**` count= 8 elapsed=487.639ms + path segments=30 `**` count= 9 elapsed=2.0743738s + path segments=30 `**` count=10 elapsed=8.50591s + PROBE HIT: path segments=30, `**` count=11: MatchGlob did not return within 20s for ONE + pattern against ONE path (pattern = "**/**/**/**/**/**/**/**/**/**/**/zzz") + +=== RUN TestProbeP6bRuleLevelBlowup + ScanRule.Matches over 200 changed paths, 8 `**`: match=false err= elapsed=29.3382633s + PROBE HIT: one rule, one pattern, 200 changed paths = 29.3382633s of CPU inside Evaluate +``` + +An earlier run of the same probe at 12 `**` segments **exceeded a ten-minute test timeout**. + +`Matches` loops `anyGlobMatches` per changed path (engine.go:585, 601), so the per-path cost is +multiplied by the change set, and `Evaluate` loops that per rule. Eight `**` segments is a typable +pattern, not an adversarial one, and 200 changed paths is a small PR. The failure mode is a wedged +`Evaluate`, and this package's own header says the policy file "decides whether a security scan +happens at all" — a scan that never starts because the evaluator is spinning is +research/09 Risk #4's failure mode again (a reviewer reads no signal as no problem). The file is read +from the repository under scan, which on the public-repo path is not fully trusted input. + +**Proposed fix:** the standard linear `**` walk (advance greedily with one backtrack point per `**`, +or memoise on `(len(pat), len(seg))`). Either is a small, testable change; the current recursion is +the only part of an otherwise disciplined engine that is not bounded. Add a benchmark or a bounded +`-timeout` test with a 12-`**` pattern as the regression. + +### O4-M5 — MAJOR. Clock 2's store-side sweep has no caller: the in-memory expiry and the durable expiry are never driven together. `handoff.go`, `deadlines.go:53-54` + +deadlines.go names two owners for clock 2's due-check: *"record.Sealer.ExpireIfDue in memory, and +handoff.Queue.ExpireClaimTimeouts against the store."* `applyTick` drives the first (statemachine.go:983). +Nothing drives the second: + +``` +$ grep -rn "ExpireClaimTimeouts\|\.Reap(\|Queue.Run" --include=*.go . | grep -v internal/handoff/ +./internal/scanctl/deadlines.go:54: // handoff.Queue.ExpireClaimTimeouts against the store. This file supplies +./internal/scanctl/deadlines.go:574: // memory and handoff.Queue.ExpireClaimTimeouts owns it in the store, and +./internal/scanctl/deadlines.go:595: // false }`) and the same one handoff.Queue.ExpireClaimTimeouts makes (`if +./internal/scanctl/deadlines.go:691: // handoff.Queue.ExpireClaimTimeouts decide that, and a live claim is never +``` + +Four comments, no call. `Consumer` (handoff.go:567) surfaces `ReclaimExpired` — clock 1 only — and +neither `ExpireClaimTimeouts` nor `Reap` (which runs both sweeps in the load-bearing order, +reaper.go:371) nor `Run`. The adapter is the *only* file in the tree that sees both clocks, and it +neither wires nor documents who drives the store-side one. + +The resulting divergence is the exact shape §6 ruling G10 catalogues: the controller marks an audit +`expired` in memory while its `handoff` rows remain `'ready'` and keep being leased, because +"40's ready-set index still sees the finding as 'ready', so it is re-leased forever". The escape +hatch (`Queue()`) makes the fix reachable, but reachable is not wired, and nothing in the reviewed +files says whose job it is. + +**Proposed fix:** either have `Consumer` expose `Reap`/`Run` and state that a daemon must drive it on +the same schedule as `NextWake`, or add an explicit "who runs the reaper" section to handoff.go's +header naming the owning step. A comment that names an owner four times without a call site is a +coordination gap, not documentation. + +--- + +## 5. Minor findings + +- **O4-m1.** `applyTick` (statemachine.go:957) can mutate the Sealer and *then* return an error: it + calls `SealHalf(dast, timed_out)` and `publish`, and only afterwards calls `ExpireIfDue`, whose + error path returns before `project`. `Transition`'s contract — *"A refused transition changes + nothing, in the Sealer or in the returned value"* — is therefore not total. Reachability is low + (`ExpireIfDue` errors only on `ErrUnknownAudit`, i.e. a concurrent `Forget`), but when it happens + the version bump is lost and the caller's record permanently disagrees with the Sealer about the + DAST half. Either make the statement conditional or capture the seal before the second call. +- **O4-m2.** The source guards are narrower than the code they defend. + `TestTheAdapterUsesNoBareEnumLiteral` and `TestTheAdapterOpensNoDatabaseAndWritesNoSQL` call + `parseAdapter`, which parses **`handoff.go` only** (handoff_test.go:919) — `statemachine.go` and + `deadlines.go` are unguarded (they are clean today; I checked by hand). And + `internal/record`'s `TestReadGateArmsAppearOnlyInsideTheGate` parses `"."`, so nothing prevents a + future author adding a `== record.StateExpired` comparison to a readability path *in this package*. + Given O4-B1, that guard is worth extending here. +- **O4-m3.** `applyCorrelation` (statemachine.go:1005) **replaces** `Correlation` wholesale rather + than appending. research/21 §5 describes correlation as "populated as both sides land", which reads + incremental; if R.12's correlator emits partial batches, the SAST-side clusters are discarded when + the DAST-side batch arrives. Neither `CorrelateEvent` nor `AuditRecord.Correlation` states which + contract the correlator must honour, and `TestCorrelationIsCopiedNotAliased` only checks aliasing. + Document it and test the two-batch case. +- **O4-m4.** `Controller.SetClock` (statemachine.go:755) writes `c.now` with no lock while + `Transition`, `applyTick`, `publish` and `NextWake` read it. `record.Sealer.SetClock` takes its + mutex for the same assignment. Construction-time use is safe; a daemon that re-clocks at runtime + has a data race that this host's `-race` ban means only CI can see. +- **O4-m5.** `DeadlinePolicy.Resolve` (deadlines.go:442-446) returns early when `DastEnabled` is + false and never validates `DastDeadlineSeconds`, so a policy with `dastDeadlineSeconds: -1` and DAST + off resolves clean. Harmless today; it means a config error survives until the tier is installed. +- **O4-m6.** No test in the package creates a goroutine, and `-race` cannot run on the dev host. The + package's central type carries an explicit concurrency contract (statemachine.go:699-706) with zero + coverage. Given that `internal/handoff/reaper.go:415` records a real concurrency bug that "reproduced + on ubuntu-latest under `-race` while passing every run on the Windows dev host", the absence here is + a coverage gap, not a style note. + +--- + +## 6. The packet's four required coverage points, answered directly + +1. **Re-entrancy races — two consumers racing an expired lease.** *No new defect.* The adapter adds + no second lease clock, no second reaper and no Go-side re-check of the consumption gate; every + mutation is `internal/handoff`'s compare-and-swap on `(state='leased', claimed_by, + lease_expires_at)`, and `Task` keeps the `handoff.Handle` unexported so the CAS cannot be routed + around (verified: `taskOf` is the only constructor, and `TestAHandBuiltTaskGrantsNothing` covers + the forgery path). The idempotency key is stable across reclaim and this is properly tested with a + distinct-side-effect counter. **Caveat:** the guarantee is inherited, not demonstrated here — no + scanctl test races two consumers, and O4-M1 supplies a *new* way to break an in-flight lease + (a spurious version bump ⇒ `ErrRecordVersionChanged` on renew, release and packet read). + Proposed scenario: two `ConsumeOne` goroutines on one fingerprint after `f.clock.advance(lease+1)`, + asserting exactly one `Applied` outcome and `attempts == 2`. +2. **Deadline anchoring — scan start, not last write.** Clock 2: **correct and defended in depth**; + the anchor resolution against `audit_record.created_at` (deadlines.go:83-124) is the best-argued + passage in the three files, and probe P10 confirms the Sealer is unfoolable. Clock 3: **O4-B2**, + blocking — same anchor on paper, no immutable substrate in practice. +3. **Completeness of the state machine against the four states.** The four-state machine is correctly + *absent*; §6 G2 struck it and O.2 emits R.1's six values via `record.DeriveState` only. + `TestEverySixthStateIsReachable` and `driveToState` cover all six, `TestSettledIsTotalOverTheStateEnum` + fails if R.1 changes arity, and no `complete`/`open` token survives anywhere. **PASS.** +4. **Is the residual check-run risk isolated from the daemon-side record?** Structurally yes — there + is no GitHub coupling of any kind in the package, so a stalled, rate-limited or rejected check-run + update has no path into a transition. Two qualifications, neither of which I would call blocking on + its own: the invariant is asserted by prose and by absence with no test named for it; and O4-B2 is + the concrete mechanism by which the isolation would fail the moment a publisher is handed an + `AuditRecord`, because `Deadlines` is writable from anywhere holding one. Fixing O4-B2 converts + this from "isolated because nothing does it yet" to "isolated by construction". + +--- + +## 7. Unverified — items I could not close, stated so the orchestrator re-runs them + +- **`go test -race` was not run.** `cgo.exe: exit status 2` on this Windows host, as the dispatch + predicted. Every concurrency statement above (O4-M3, O4-m4, and the re-entrancy assessment) is + based on reading and on single-process behavioural probes, not on the race detector. Given that + `internal/handoff/reaper.go:415` records a real bug that only CI's Linux `-race` caught, **treat the + local green as provisional for anything in §4 and §5 touching concurrency.** +- **Probe reproduction.** My probes were injected with `go test -overlay` and are not in the tree, per + my write restriction. Each finding states its construction precisely enough to re-create, but a + re-runner must write them; there is no artifact to execute. +- **Fork-PR threat model for O4-M4.** I established that the glob cost is unbounded and that the + pattern comes from the scanned repository. I did **not** establish which ref's `.anvil/policy.yml` + the Action or the daemon actually evaluates for a fork PR — that is O.8's and O.11's territory. If + the base-branch policy is always used, O4-M4 is a self-inflicted-hang bug; if a PR head's policy is + ever evaluated, it is untrusted input. The fix is the same either way; the severity is not. +- **`schemas/policy.schema.json` conformance.** I read it for bounds (`maxItems`/`maxLength`: none + anywhere) and spot-checked it against `keysPolicy`/`keysSettings`/`keysScanRule`. I did not audit it + clause by clause against `FromDocument`; `TestDecoderKeySetsMatchSchema` claims to, and I did not + independently re-derive that claim. +- **`internal/policy/semver.go` (O.7)** was read for hard-coded policy (`gitTimeout`, `maxTagWalk` are + guards with derivations, correctly labelled) and for a second enum (none — it returns `BumpKind`). + Its git plumbing, shallow-checkout detection and semver parser were **not** exercised against a real + repository by me; `semver_test.go` is 524 lines and I read only its function list. +- **CRITIQUE-01/02/03 open items** were read for overlap and none of the findings above duplicates or + worsens one. I did not re-verify their open items themselves. diff --git a/internal/scanctl/critique_o4_regression_test.go b/internal/scanctl/critique_o4_regression_test.go new file mode 100644 index 0000000..f1bc893 --- /dev/null +++ b/internal/scanctl/critique_o4_regression_test.go @@ -0,0 +1,826 @@ +package scanctl + +// Regression tests for REVIEW-O.4, one test per finding, each named for the +// finding it closes. +// +// They are in their own file for the reason internal/record keeps +// critique03_regression_test.go separate: a defect that was found once is a +// defect that can return, and a test whose name does not say which defect it +// guards gets deleted during the next refactor by someone who cannot tell what +// it was protecting. +// +// EVERY TEST HERE RECONSTRUCTS THE CRITIC'S OWN PROBE, not a convenient +// approximation of it. Where the probe's construction has become impossible to +// express — the two blockers both rested on assigning to a field that no longer +// exists — the test asserts the impossibility instead, and says so, because +// "you cannot write that any more" is the actual fix and needs to be the thing +// that fails if somebody re-exports the field. +// +// The critic's own summary of why the green suite proved nothing: "three of the +// seven confirmed defects are reached only by holding an AuditRecord across a +// state change, and every test in the package constructs a fresh one and +// immediately consumes it." So these tests all hold a record across something. + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "testing" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/handoff" + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// O4-B1 — the read gate must be asked about the LIVE audit +// --------------------------------------------------------------------------- + +// Probe P3, reconstructed: Begin, one SAST finding, seal SAST, KEEP the returned +// record, advance the clock past the 8h claim window, tick on a different copy, +// then read through the stale record. +// +// The critic's result was "1 finding(s) read out of an EXPIRED audit through the +// stale record; AuditRecord.Readable()=true, errors.Is(err, ErrHalfNotSealed)=false" +// while record.Sealer.ReadHalf on the same audit refused. The read surface is now +// Controller.Findings, which re-Inspects and goes through +// record.Sealer.ReadHalf, so the stale record supplies an audit id and nothing +// the gate could be misled by. +func TestFindingsAreGatedAgainstTheLiveAuditNotAHeldSnapshot(t *testing.T) { + ctl, clk := newTestController(t, sastOnlyPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "P3") + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, finding("a"))) + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + + // The stale record: sealed SAST half, audit not yet expired, readable. + stale := rec + if got, err := ctl.Findings(stale, record.HalfSast); err != nil || len(got) != 1 { + t.Fatalf("precondition: Findings before expiry = (%d, %v), want (1, nil)", len(got), err) + } + if !ctl.Readable(stale, record.HalfSast) { + t.Fatal("precondition: the sealed SAST half must be readable before the window closes") + } + + // The claim window closes, and the tick lands on a DIFFERENT copy — which + // is the whole construction. `stale` still says sast_sealed. + clk.set(8 * time.Hour) + live := mustTransition(t, ctl, rec, TickEvent()) + if live.State != record.StateExpired { + t.Fatalf("live State = %q, want %q", live.State, record.StateExpired) + } + if stale.State == record.StateExpired { + t.Fatal("the held record was mutated; the probe needs it to be stale, and " + + "AuditRecord is meant to be an immutable snapshot") + } + + // THE FINDING. Reading through the stale record must be refused, with the + // same error record.Sealer.ReadHalf gives, because it IS that error. + got, err := ctl.Findings(stale, record.HalfSast) + if !errors.Is(err, record.ErrHalfNotSealed) { + t.Fatalf("Findings through a stale record on an EXPIRED audit = (%d findings, %v); "+ + "want a read-gate refusal (O4-B1)", len(got), err) + } + if got != nil { + t.Errorf("a refused read returned %d findings; it must return nothing", len(got)) + } + if ctl.Readable(stale, record.HalfSast) { + t.Error("Readable said true through a stale record on an expired audit (O4-B1)") + } + + // And it agrees with the Sealer, asked directly. The critic's probe is + // exactly the observation that these two disagreed. + _, sealerErr := ctl.Sealer().ReadHalf(stale.AuditID, record.HalfSast) + if (err == nil) != (sealerErr == nil) { + t.Errorf("Controller.Findings and record.Sealer.ReadHalf disagree: %v vs %v", err, sealerErr) + } +} + +// The other half of O4-B1's fix: there is no snapshot-scoped read surface left +// to reach for. A future author who adds `func (r AuditRecord) Findings(...)` +// back re-opens the defect at the moment they add it, and this fails then. +// +// It is a reflection test rather than a source guard because the property is +// about the TYPE's method set, which is what a caller sees and what the mistake +// consists of. +func TestAuditRecordExposesNoUngatedReadSurface(t *testing.T) { + banned := map[string]string{ + "Findings": "results must come from Controller.Findings, which gates against the live audit", + "Readable": "readability must come from Controller.Readable, for the same reason", + "HalfSeal": "a seal assembled from a snapshot's own fields is CRITIQUE O.4 blocker 1; " + + "record.Sealer mints the only seals the gate will believe", + } + for _, typ := range []reflect.Type{ + reflect.TypeOf(AuditRecord{}), + reflect.TypeOf(&AuditRecord{}), + reflect.TypeOf(HalfRecord{}), + } { + for i := 0; i < typ.NumMethod(); i++ { + name := typ.Method(i).Name + if why, bad := banned[name]; bad { + t.Errorf("%s has method %s: %s", typ, name, why) + } + } + } + + // The findings themselves stay unexported, so there is no ungated field + // read either. FindingCount is the one deliberate exception and is + // documented as metadata rather than results. + half := reflect.TypeOf(HalfRecord{}) + for i := 0; i < half.NumField(); i++ { + f := half.Field(i) + if f.Type == reflect.TypeOf([]record.Result(nil)) && f.IsExported() { + t.Errorf("HalfRecord.%s exports a []record.Result; an exported results slice is an ungated read", f.Name) + } + } +} + +// --------------------------------------------------------------------------- +// O4-B2 — clock 3 must not be movable by assignment +// --------------------------------------------------------------------------- + +// Probe P10's first half: hold a record, move the DAST deadline out to t+7h59m, +// tick at t+5h, and observe that the forced seal does not fire. The critic's +// result was "dast=running dastStatus=running state=collecting" with the Sealer +// still holding the true dast_deadline_seconds=14400. +// +// The assignment the probe made is now a compile error, so the test makes the +// STRONGEST assignment still available — replacing the whole Deadlines value +// with one built by a different, much longer policy — and asserts clock 3 fires +// on the Sealer's schedule regardless. +func TestClockThreeCannotBeMovedByAssignment(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "P10") + rec = mustTransition(t, ctl, rec, DastOutcomeEvent(bootedCleanOutcome())) + + at, ok := rec.Deadlines.DastDeadline() + if !ok || !at.Equal(baseTime.Add(4*time.Hour)) { + t.Fatalf("precondition: DastDeadline = (%s, %v), want (%s, true)", at, ok, baseTime.Add(4*time.Hour)) + } + + // The attack, in the only shape the type still permits: build a Deadlines + // whose DAST clock is at t+7h30m and put it on the record. + sevenHalf := int((7*time.Hour + 30*time.Minute).Seconds()) + slow, err := DeadlinePolicy{DastEnabled: true, DastDeadlineSeconds: &sevenHalf}.At(baseTime) + if err != nil { + t.Fatalf("building the attacker's Deadlines: %v", err) + } + rec.Deadlines = slow + if at, _ := rec.Deadlines.DastDeadline(); !at.Equal(baseTime.Add(7*time.Hour + 30*time.Minute)) { + t.Fatalf("the test did not manage to move the record's own copy: %s", at) + } + + // THE FINDING. At the REAL deadline the half is forced terminal anyway, + // because record.Sealer.SealDastIfDeadlineDue decides against the audit's + // own startedAt + dast_deadline_seconds, which BeginAudit fixed. + clk.set(4 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if rec.Dast.Status != record.HalfStatusTimedOut { + t.Fatalf("Dast.Status = %q at the real clock 3, want %q; clock 3 was moved by assignment (O4-B2)", + rec.Dast.Status, record.HalfStatusTimedOut) + } + if rec.DastStatus != record.DastStatusTimedOut { + t.Errorf("DastStatus = %q, want %q", rec.DastStatus, record.DastStatusTimedOut) + } + // Probe P10's control, restated: clock 2 was never foolable, and still is + // not. Pushing the whole Deadlines out does not postpone expiry. + clk.set(8 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if rec.State != record.StateExpired { + t.Errorf("State = %q at the claim deadline, want %q", rec.State, record.StateExpired) + } +} + +// The structural half of O4-B2. The critic's argument was that Deadlines' +// contract — "THE ONLY WAY TO CHANGE A DEADLINE IS TO START A NEW SCAN" — was +// enforced against METHODS and not against FIELDS, and that the field was +// exported. This asserts the enforcement rather than the prose. +func TestDeadlinesExposeNoAssignableClock(t *testing.T) { + typ := reflect.TypeOf(Deadlines{}) + for i := 0; i < typ.NumField(); i++ { + if f := typ.Field(i); f.IsExported() { + t.Errorf("Deadlines.%s is exported: a deadline a caller can assign to is CRITIQUE O.4 blocker 2, "+ + "and 'there is no method on it that mutates anything' is an argument about methods", f.Name) + } + // No pointer fields either: two copies of a Deadlines sharing a + // *time.Time would be an assignable clock reached one dereference + // further away. + if k := typ.Field(i).Type.Kind(); k == reflect.Pointer || k == reflect.Slice || k == reflect.Map { + t.Errorf("Deadlines.%s is a %s; a copy would share it, which is the same defect one indirection out", + typ.Field(i).Name, k) + } + } + + // And there is no due-check here to be fooled. Clock 3's decision belongs to + // record.Sealer.SealDastIfDeadlineDue; a predicate on this type would be a + // third owner over an input nobody re-reads. + for i := 0; i < typ.NumMethod(); i++ { + if name := typ.Method(i).Name; name == "DastDeadlineElapsed" { + t.Error("Deadlines.DastDeadlineElapsed is back: clock 3's due-check belongs to " + + "record.Sealer.SealDastIfDeadlineDue, which owns the substrate (O4-B2)") + } + } +} + +// --------------------------------------------------------------------------- +// O4-M1 — a redelivered seal event must not bump audit_version +// --------------------------------------------------------------------------- + +// Probe P1: seal the same half with the same status three times. The critic +// measured version 2 -> 3 -> 4 with record.Sealer.SealHalf returning nil each +// time, having treated the second and third as idempotent no-ops. +// +// The cost is paid in two other packages: every bump obliges S6's queue re-cut +// (R.11), and internal/handoff re-checks audit_record.audit_version on every +// mutation and answers handoff.ErrRecordVersionChanged, so a duplicate delivery +// invalidated every in-flight lease on the audit. +func TestARedeliveredSealEventDoesNotBumpTheVersion(t *testing.T) { + ctl, _ := newTestController(t, sastOnlyPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "P1") + + first := mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if !VersionBumped(rec, first) { + t.Fatalf("the FIRST seal must publish: version %d -> %d", rec.Version, first.Version) + } + sealedAt := first.Sast.SealedAt + if sealedAt == nil { + t.Fatal("precondition: a sealed half carries anvil/sealedAt") + } + + // The redeliveries. At-least-once delivery, a retried store write, or two + // workers fanning the same seal in all produce this. + prev := first + for i := 0; i < 3; i++ { + again, err := ctl.Transition(prev, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if err != nil { + t.Fatalf("redelivery %d: %v", i, err) + } + if VersionBumped(prev, again) { + t.Fatalf("redelivery %d bumped audit_version %d -> %d; the Sealer treated it as a no-op "+ + "and a bump re-cuts the queue (O4-M1)", i, prev.Version, again.Version) + } + if again.Sast.SealedAt == nil || !again.Sast.SealedAt.Equal(*sealedAt) { + t.Errorf("redelivery %d moved anvil/sealedAt: %v -> %v", i, sealedAt, again.Sast.SealedAt) + } + prev = again + } + + // NEGATIVE CONTROL. The suppression must be keyed on "nothing changed", not + // on "the event was a seal": a seal that DOES change the record still + // publishes. Without this the test above would pass if publication had been + // removed from the seal path entirely. + dast, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec2 := mustBegin(t, dast, "P1-control") + rec2 = mustTransition(t, dast, rec2, DastOutcomeEvent(bootedCleanOutcome())) + before := rec2 + rec2 = mustTransition(t, dast, rec2, SealHalfEvent(record.HalfDast, record.HalfStatusSealed)) + if !VersionBumped(before, rec2) { + t.Error("a seal that really sealed did not publish; the O4-M1 fix has suppressed real bumps too") + } +} + +// A tick that finds nothing to do is the same class of no-op and must not +// publish either. Daemons wake on a schedule, so this is the highest-frequency +// path in the system and a spurious bump here would re-cut the queue every wake. +func TestAnIdleTickDoesNotBumpTheVersion(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "idle-ticks") + + for i := 1; i <= 5; i++ { + clk.set(time.Duration(i) * time.Minute) + before := rec + rec = mustTransition(t, ctl, rec, TickEvent()) + if VersionBumped(before, rec) { + t.Fatalf("tick %d bumped audit_version %d -> %d with nothing due", i, before.Version, rec.Version) + } + } +} + +// --------------------------------------------------------------------------- +// O4-M2 — the write guards must consult the Sealer, not the caller's snapshot +// --------------------------------------------------------------------------- + +// Probe P2: hold a record from before expiry and push findings and correlation +// through it. The critic's result was "findings accepted onto an EXPIRED audit +// via a stale record; projected state=expired, buffered sast findings=1" and the +// same for correlation — with acceptsWrites' own doc naming that as the thing it +// existed to prevent. +func TestWriteGuardsConsultTheSealerNotTheCallersSnapshot(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "P2") + rec = mustTransition(t, ctl, rec, DastOutcomeEvent(bootedCleanOutcome())) + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfDast, record.HalfStatusSealed)) + + stale := rec // state = dast_sealed, and it will stay saying that + clk.set(8 * time.Hour) + live := mustTransition(t, ctl, rec, TickEvent()) + if live.State != record.StateExpired || stale.State != record.StateDastSealed { + t.Fatalf("precondition: live=%q stale=%q, want %q and %q", + live.State, stale.State, record.StateExpired, record.StateDastSealed) + } + + for _, tc := range []struct { + name string + ev Event + }{ + {"findings", FindingsEvent(record.HalfSast, finding("late"))}, + {"correlation", CorrelateEvent(record.Correlation{ClusterID: "c1", Role: record.HalfSast})}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := ctl.Transition(stale, tc.ev) + if !errors.Is(err, record.ErrAuditTerminal) { + t.Fatalf("%s onto an EXPIRED audit via a stale record = %v, want record.ErrAuditTerminal (O4-M2)", + tc.name, err) + } + if out.AuditID != stale.AuditID || out.Version != stale.Version { + t.Errorf("a refused transition did not return the input unchanged: %+v", out) + } + }) + } + + // Nothing landed. Combined with O4-B1, findings buffered after expiry would + // also have been readable through the same stale record. + current, ok := ctl.Record(stale.AuditID) + if !ok { + t.Fatal("Record: the audit vanished") + } + if current.Sast.FindingCount() != 0 { + t.Errorf("Sast.FindingCount = %d after two refused writes, want 0", current.Sast.FindingCount()) + } + if len(current.Correlation) != 0 { + t.Errorf("Correlation has %d clusters after a refused write, want 0", len(current.Correlation)) + } +} + +// --------------------------------------------------------------------------- +// O4-M3 — concurrent fan-in must not lose findings +// --------------------------------------------------------------------------- + +// Probe P11: eight workers x three DAST findings, all transitioning the SAME +// record. The critic measured "the surviving record holds 3 ... PROBE HIT: 21 of +// 24 findings lost", on a security scanner, silently — while the type's doc +// called the failure mode "a skipped version bump, not a corrupt lifecycle". +// +// Every goroutine deliberately passes the same STALE snapshot, because that is +// what a fan-in caller has: eight workers that each took the record once and are +// now reporting independently. Serialising Transition externally would not have +// saved the old design, which is why the fix was to move the buffers onto the +// Controller rather than to write a warning. +// +// -race cannot run on the Windows dev host; CI runs this on ubuntu-latest, where +// this repository has already had one concurrency bug that passed every local +// run (internal/handoff/reaper.go:415). +func TestConcurrentFanInLosesNoFindings(t *testing.T) { + const ( + workers = 8 + perWorker = 3 + wantTotal = workers * perWorker + bigEnoughForN = 1000 + longEnoughForM = time.Hour + ) + + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{ + DastFindings: bigEnoughForN, Interval: longEnoughForM, + }) + shared := mustBegin(t, ctl, "P11") + shared = mustTransition(t, ctl, shared, DastOutcomeEvent(bootedCleanOutcome())) + + var wg sync.WaitGroup + errs := make(chan error, workers*perWorker) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + // The same stale record every time, from every goroutine. + if _, err := ctl.Transition(shared, FindingsEvent( + record.HalfDast, finding(fmt.Sprintf("w%d/%d", w, i)))); err != nil { + errs <- fmt.Errorf("worker %d finding %d: %w", w, i, err) + } + } + }(w) + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + + got, ok := ctl.Record(shared.AuditID) + if !ok { + t.Fatal("Record: the audit vanished") + } + if n := got.Dast.FindingCount(); n != wantTotal { + t.Fatalf("%d of %d DAST findings survived concurrent fan-in; %d were lost (O4-M3)", + n, wantTotal, wantTotal-n) + } + if got.PendingDastFindings != wantTotal { + t.Errorf("PendingDastFindings = %d, want %d: the watermark counter must count every arrival too", + got.PendingDastFindings, wantTotal) + } +} + +// The version counter must count publications rather than the last writer's +// opinion of how many there were. With N=1 every finding publishes, so the +// version after the fan-in is exactly 1 + the number of findings. +func TestConcurrentFanInCountsEveryPublication(t *testing.T) { + const workers, perWorker = 6, 4 + + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1, Interval: time.Hour}) + shared := mustBegin(t, ctl, "P11-versions") + shared = mustTransition(t, ctl, shared, DastOutcomeEvent(bootedCleanOutcome())) + start := shared.Version + + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + if _, err := ctl.Transition(shared, FindingsEvent( + record.HalfDast, finding(fmt.Sprintf("w%d/%d", w, i)))); err != nil { + t.Errorf("worker %d finding %d: %v", w, i, err) + } + } + }(w) + } + wg.Wait() + + got, _ := ctl.Record(shared.AuditID) + if want := start + workers*perWorker; got.Version != want { + t.Errorf("Version = %d after %d publishing transitions, want %d; bumps were lost to the "+ + "last-writer-wins counter (O4-M3)", got.Version, workers*perWorker, want) + } +} + +// O4-m4: SetClock wrote c.now with no lock while Transition, applyTick, publish +// and NextWake read it. record.Sealer.SetClock takes its mutex for the same +// assignment. This is the probe; the race detector on CI is what makes it +// meaningful, and locally it at least proves the two paths do not deadlock +// against each other under the documented lock order. +func TestSetClockIsSafeAgainstConcurrentTransitions(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1000, Interval: time.Hour}) + rec := mustBegin(t, ctl, "reclocking") + + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + // A daemon re-clocking at runtime is the case the finding names. + ctl.SetClock((&testClock{at: baseTime.Add(time.Duration(i) * time.Minute)}).now) + } + }() + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + if _, err := ctl.Transition(rec, FindingsEvent(record.HalfDast, finding("f"))); err != nil { + t.Errorf("Transition: %v", err) + } + } + }() + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + ctl.NextWake(rec) + if _, err := ctl.Transition(rec, TickEvent()); err != nil { + t.Errorf("tick: %v", err) + } + } + }() + wg.Wait() + + // Leave the controller on a determinate clock for anything that follows. + ctl.SetClock(clk.now) + if _, ok := ctl.Record(rec.AuditID); !ok { + t.Fatal("the audit did not survive concurrent re-clocking") + } +} + +// --------------------------------------------------------------------------- +// O4-M5 — clock 2's two sweeps must be drivable together +// --------------------------------------------------------------------------- + +// The critic's grep found ExpireClaimTimeouts named in four comments in this +// package and called from nowhere in the tree, so in-memory expiry and durable +// expiry were never driven together: the controller marks an audit `expired` +// while its handoff rows stay 'ready' and keep being leased (§6 ruling G10). +// +// This drives the wiring end to end against the real schema: a ready finding on +// an audit whose claim window has closed must reach 'expired' through the +// Consumer, and must do so through Reap — the entry point that runs BOTH sweeps +// in the reaper's own order. +func TestConsumerDrivesTheStoreSideClaimTimeoutSweep(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + _, row := f.enqueue(90, record.ConsumptionClassStaticOnly, audit, 3) + + if got := f.rowState(row.HandoffID); got != record.HandoffStateReady { + t.Fatalf("precondition: row state = %q, want %q", got, record.HandoffStateReady) + } + + // Inside the claim window nothing is swept. A sweep that expired a live + // claim window would be a different and worse bug. + f.clock.advance(hfPolicy().ClaimTimeout() - time.Minute) + report, err := f.c.Reap() + if err != nil { + t.Fatalf("Reap inside the window: %v", err) + } + if len(report.Expired) != 0 { + t.Fatalf("Reap expired %d rows inside the claim window", len(report.Expired)) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateReady { + t.Fatalf("row state = %q inside the window, want %q", got, record.HandoffStateReady) + } + + // Past `audit_record.deadline_at` the store side agrees with what + // record.Sealer.ExpireIfDue would say in memory. + f.clock.advance(2 * time.Minute) + report, err = f.c.Reap() + if err != nil { + t.Fatalf("Reap past the deadline: %v", err) + } + if len(report.Expired) != 1 || report.Expired[0].HandoffID != row.HandoffID { + t.Fatalf("Reap.Expired = %+v, want exactly the one row (O4-M5)", report.Expired) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateExpired { + t.Fatalf("row state = %q after the claim window closed, want %q; the store-side sweep "+ + "is still unwired and the row would be re-leased forever (O4-M5)", got, record.HandoffStateExpired) + } + + // The narrow entry point is reachable too, for a caller that owns the + // ordering itself, and it is idempotent. + if _, err := f.c.ExpireClaimTimeouts(); err != nil { + t.Fatalf("ExpireClaimTimeouts: %v", err) + } +} + +// Reap must run BOTH sweeps, in the order reaper.go argues for: a crashed +// holder's row has to be reclaimed out of 'leased' before the claim-timeout +// sweep can see it as 'ready' and expire it. Running only the lease sweep, or +// running them in the other order, leaves the row alive for a whole extra +// interval — and running only the timeout sweep never sees it at all. +func TestReapRunsTheLeaseSweepBeforeTheClaimTimeoutSweep(t *testing.T) { + f := newHFFixture(t, handoff.Options{Lease: 20 * time.Minute}) + audit := f.sealedAudit() + fingerprint, row := f.enqueue(91, record.ConsumptionClassStaticOnly, audit, 3) + + if _, err := f.c.Claim(fingerprint, "worker-doomed"); err != nil { + t.Fatalf("Claim: %v", err) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("precondition: row state = %q, want %q", got, record.HandoffStateLeased) + } + + // The holder is OOM-killed AND the claim window closes: both clocks are due + // on the same wake, which is the case the ordering exists for. + f.clock.advance(hfPolicy().ClaimTimeout() + time.Minute) + + report, err := f.c.Reap() + if err != nil { + t.Fatalf("Reap: %v", err) + } + if len(report.Reclaimed) != 1 { + t.Fatalf("Reap.Reclaimed = %+v, want the crashed holder's row", report.Reclaimed) + } + if len(report.Expired) != 1 { + t.Fatalf("Reap.Expired = %+v, want the same row expired in the SAME sweep; the two sweeps "+ + "ran in the wrong order or only one of them ran", report.Expired) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateExpired { + t.Fatalf("row state = %q, want %q after one Reap", got, record.HandoffStateExpired) + } +} + +// Run is the loop a daemon starts. It must sweep, hand each report to observe, +// and return ctx.Err() on cancellation rather than reporting the cancellation as +// a sweep failure. +func TestConsumerRunSweepsUntilCancelled(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + _, row := f.enqueue(92, record.ConsumptionClassStaticOnly, audit, 3) + f.clock.advance(hfPolicy().ClaimTimeout() + time.Minute) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + swept := make(chan handoff.ReapReport, 8) + done := make(chan error, 1) + go func() { + done <- f.c.Run(ctx, time.Millisecond, func(r handoff.ReapReport, err error) { + if err != nil { + t.Errorf("sweep error: %v", err) + return + } + select { + case swept <- r: + default: + } + }) + }() + + deadline := time.After(10 * time.Second) + for { + select { + case r := <-swept: + if len(r.Expired) == 0 { + continue + } + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Errorf("Run returned %v, want context.Canceled", err) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateExpired { + t.Errorf("row state = %q, want %q", got, record.HandoffStateExpired) + } + return + case <-deadline: + cancel() + <-done + t.Fatal("Run never swept the due row within 10s") + } + } +} + +// --------------------------------------------------------------------------- +// O4-m1 — a refused transition must change nothing +// --------------------------------------------------------------------------- + +// The finding: applyTick sealed the DAST half and bumped the version, and only +// afterwards called something that could return an error — on which path the +// bump was discarded while the Sealer kept the seal, so "A refused transition +// changes nothing, in the Sealer or in the returned value" was not total. +// +// The fix is structural: Transition applies every event to a working copy and +// commits with one assignment below every error return. This asserts the +// resulting property over every refusal the package can actually produce, from +// every state, by comparing the CONTROLLER's own view before and after — which +// is the half the old test could not see, because it only compared the returned +// value. +func TestARefusedTransitionLeavesTheControllerUnchanged(t *testing.T) { + type refusal struct { + name string + from record.State + ev Event + } + refusals := []refusal{ + {"seal as running", record.StateCollecting, SealHalfEvent(record.HalfSast, record.HalfStatusRunning)}, + {"seal an unknown half", record.StateCollecting, SealHalfEvent(record.Half("both"), record.HalfStatusSealed)}, + {"re-seal differently", record.StateSastSealed, SealHalfEvent(record.HalfSast, record.HalfStatusFailed)}, + {"seal a consumed audit", record.StateConsumed, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + {"seal an expired audit", record.StateExpired, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + {"consume too early", record.StateSastSealed, ConsumeEvent()}, + {"findings on a sealed half", record.StateSastSealed, FindingsEvent(record.HalfSast, finding("late"))}, + {"findings on an expired audit", record.StateExpired, FindingsEvent(record.HalfSast, finding("late"))}, + {"empty findings", record.StateCollecting, FindingsEvent(record.HalfSast)}, + {"empty correlation", record.StateCollecting, CorrelateEvent()}, + {"correlation on a consumed audit", record.StateConsumed, CorrelateEvent(record.Correlation{ClusterID: "c"})}, + {"outcome on a sealed half", record.StateDastSealed, DastOutcomeEvent(bootedCleanOutcome())}, + {"the zero event", record.StateCollecting, Event{}}, + {"an invented kind", record.StateCollecting, Event{Kind: EventKind("seal")}}, + } + + for _, tc := range refusals { + t.Run(tc.name, func(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec, err := driveToState(t, ctl, clk, tc.from) + if err != nil { + t.Fatalf("driving to %q: %v", tc.from, err) + } + before, ok := ctl.Record(rec.AuditID) + if !ok { + t.Fatal("Record: the audit vanished") + } + + if _, err := ctl.Transition(rec, tc.ev); err == nil { + t.Fatalf("Transition(%s) from %q succeeded; this table is refusals only", tc.ev.Kind, tc.from) + } + + after, ok := ctl.Record(rec.AuditID) + if !ok { + t.Fatal("Record: the audit vanished after a refusal") + } + if diff := describeDrift(before, after); diff != "" { + t.Errorf("a refused transition changed the controller's own state: %s", diff) + } + }) + } +} + +// describeDrift names the first field on which two projections of one audit +// disagree, or "" when they agree. It exists so a failure says WHICH field +// moved rather than dumping two structs. +func describeDrift(before, after AuditRecord) string { + switch { + case before.Version != after.Version: + return fmt.Sprintf("Version %d -> %d", before.Version, after.Version) + case before.State != after.State: + return fmt.Sprintf("State %q -> %q", before.State, after.State) + case before.Sast.Status != after.Sast.Status: + return fmt.Sprintf("Sast.Status %q -> %q", before.Sast.Status, after.Sast.Status) + case before.Dast.Status != after.Dast.Status: + return fmt.Sprintf("Dast.Status %q -> %q", before.Dast.Status, after.Dast.Status) + case before.DastStatus != after.DastStatus: + return fmt.Sprintf("DastStatus %q -> %q", before.DastStatus, after.DastStatus) + case before.Sast.FindingCount() != after.Sast.FindingCount(): + return fmt.Sprintf("Sast findings %d -> %d", before.Sast.FindingCount(), after.Sast.FindingCount()) + case before.Dast.FindingCount() != after.Dast.FindingCount(): + return fmt.Sprintf("Dast findings %d -> %d", before.Dast.FindingCount(), after.Dast.FindingCount()) + case len(before.Correlation) != len(after.Correlation): + return fmt.Sprintf("correlation clusters %d -> %d", len(before.Correlation), len(after.Correlation)) + case before.PendingDastFindings != after.PendingDastFindings: + return fmt.Sprintf("PendingDastFindings %d -> %d", before.PendingDastFindings, after.PendingDastFindings) + case !before.PublishedAt.Equal(after.PublishedAt): + return fmt.Sprintf("PublishedAt %s -> %s", before.PublishedAt, after.PublishedAt) + case before.Deadlines != after.Deadlines: + return "Deadlines moved" + } + return "" +} + +// --------------------------------------------------------------------------- +// O4-m3 — the correlation contract, stated and tested +// --------------------------------------------------------------------------- + +// The finding: applyCorrelation REPLACES rather than appends, research/21 §5's +// "populated as both sides land" reads incremental, and nothing said which +// contract R.12's correlator must honour. The contract is now written down on +// CorrelateEvent and on applyCorrelation; this is the two-batch case the critic +// asked for, asserted rather than left to the reader. +func TestACorrelationBatchReplacesTheWholeSet(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "correlation-batches") + + rec = mustTransition(t, ctl, rec, CorrelateEvent( + record.Correlation{ClusterID: "c1", Role: record.HalfSast}, + record.Correlation{ClusterID: "c2", Role: record.HalfSast}, + )) + if len(rec.Correlation) != 2 { + t.Fatalf("first batch: %d clusters, want 2", len(rec.Correlation)) + } + + // The second batch is the correlator's COMPLETE current answer, which + // happens to include the first batch's clusters plus a DAST-side one. + rec = mustTransition(t, ctl, rec, CorrelateEvent( + record.Correlation{ClusterID: "c1", Role: record.HalfSast}, + record.Correlation{ClusterID: "c2", Role: record.HalfSast}, + record.Correlation{ClusterID: "c3", Role: record.HalfDast}, + )) + if len(rec.Correlation) != 3 { + t.Fatalf("second batch: %d clusters, want 3", len(rec.Correlation)) + } + + // REPLACEMENT, not accumulation: a shorter batch shrinks the set, and the + // earlier clusters do not survive. This is the assertion that makes the + // contract testable — appending would leave five here. + rec = mustTransition(t, ctl, rec, CorrelateEvent( + record.Correlation{ClusterID: "c9", Role: record.HalfDast}, + )) + if len(rec.Correlation) != 1 || rec.Correlation[0].ClusterID != "c9" { + t.Fatalf("third batch: %+v; each batch REPLACES the set (see CorrelateEvent) and this is "+ + "the case a correlator emitting partial batches would get wrong", rec.Correlation) + } +} + +// --------------------------------------------------------------------------- +// O4-m5 — a malformed DAST deadline is malformed whether or not DAST is on +// --------------------------------------------------------------------------- + +// The finding: Resolve returned early when DastEnabled was false and never +// validated DastDeadlineSeconds, so `dastDeadlineSeconds: -1` with DAST off +// resolved clean and the config error survived until the tier was installed. +func TestResolveRejectsANegativeDastDeadlineEvenWithDastOff(t *testing.T) { + for _, secs := range []int{-1, 0} { + v := secs + for _, enabled := range []bool{false, true} { + p := DeadlinePolicy{DastEnabled: enabled, DastDeadlineSeconds: &v} + if _, err := p.Resolve(); !errors.Is(err, ErrInvalidDeadlinePolicy) { + t.Errorf("Resolve(dastDeadlineSeconds=%d, dastEnabled=%v) = %v, want ErrInvalidDeadlinePolicy "+ + "(O4-m5: a config error must not wait for the tier to be installed)", v, enabled, err) + } + // And it is refused everywhere Resolve is reached from, not only in + // the one entry point a test happened to call. + if _, err := NewController(p, WatermarkPolicy{}); !errors.Is(err, ErrInvalidDeadlinePolicy) { + t.Errorf("NewController with dastDeadlineSeconds=%d, dastEnabled=%v = %v, want a refusal", + v, enabled, err) + } + if _, err := p.At(baseTime); !errors.Is(err, ErrInvalidDeadlinePolicy) { + t.Errorf("At with dastDeadlineSeconds=%d, dastEnabled=%v = %v, want a refusal", v, enabled, err) + } + } + } + + // NEGATIVE CONTROL: a positive value with DAST off is still legal and still + // produces no clock 3. The fix must reject malformed values, not values it + // is not going to use. + fine := 3600 + resolved, err := DeadlinePolicy{DastDeadlineSeconds: &fine}.Resolve() + if err != nil { + t.Fatalf("a positive dastDeadlineSeconds with DAST off must resolve: %v", err) + } + if resolved.DastDeadlineSeconds != nil { + t.Errorf("DastDeadlineSeconds = %v with DAST off, want nil (there is no clock 3 to run)", + *resolved.DastDeadlineSeconds) + } +} diff --git a/internal/scanctl/deadlines.go b/internal/scanctl/deadlines.go new file mode 100644 index 0000000..3c438de --- /dev/null +++ b/internal/scanctl/deadlines.go @@ -0,0 +1,805 @@ +// Package scanctl is `anvil-scanctl`: the ONE named scan controller +// plan/00-SPINE.md S10 requires, holding ONE state machine with ONE owner. +// S10's reason for existing is that four research branches each specified part +// of an orchestrator (a consumption protocol with leases and ledgers, a +// correlator process, sixteen validation gates, a target-lifecycle harness), +// and "implement it as one named scan controller with one state machine and +// one owner, or it will be re-implemented inconsistently in four places." +// +// This file (step O.1) carries the package doc because it is the package's +// first file and the one every other file in it depends on. Later files in +// this package — statemachine.go (O.2), handoff.go (O.3) — must NOT add a +// second package comment. +// +// scanctl OWNS NO VOCABULARY. Every enum token it handles is a Go constant +// from internal/record, which plan/IMPLEMENTATION-PLAN.md §6 makes the single +// point where shared vocabulary is fixed: "area 40 owns every shared enum, +// because it owns the record contract, and no other area may declare one." +// Nine of the ten defects that review found were the same structural error — +// separate authors each defining the shared vocabulary from their own side. +// A bare string literal for an enum value in this package is a second +// definition and is how that recurs. +package scanctl + +import ( + "errors" + "fmt" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// THE CLOCKS — there are THREE, and this file owns exactly one of them +// --------------------------------------------------------------------------- +// +// internal/handoff/reaper.go opens with a header explaining TWO clocks and why +// conflating them is the defect plan/00-SPINE.md S1 names outright. This file +// adds the third. It does not add a fourth spelling of either of the first +// two, and every statement below is written to AGREE with the existing owner +// rather than to restate it in different words. +// +// CLOCK 1 — THE LEASE. handoff.lease_expires_at +// Owner: internal/handoff (R.7). 15-30 minutes, heartbeat-renewed. +// Governs ONE consumer attempt. Expiry means "the holder is presumed +// dead": back to 'ready' while attempts remain, terminal after that. +// scanctl NEVER computes it. internal/scanctl/handoff.go (O.3) is a thin +// adapter over R.7's protocol, per IMPLEMENTATION-PLAN.md §6 ruling G9. +// +// CLOCK 2 — THE CLAIM TIMEOUT. audit_record.deadline_at +// Owner of the FORMULA: record.ComputeDeadline — `scan_run.started_at + +// claim_timeout_seconds`, 8h by default (record.DefaultClaimTimeoutSeconds), +// computed ONCE in record.Sealer.BeginAudit and never recomputed. +// Owner of the DUE-CHECK: record.Sealer.ExpireIfDue in memory, and +// handoff.Queue.ExpireClaimTimeouts against the store. This file supplies +// the CONFIGURED INPUTS to that formula and the resulting instant; it does +// not decide expiry, and deliberately exposes no predicate that would let a +// caller decide it here. A third due-check with no substrate of its own +// would be the second-definition defect again. +// WHO DRIVES THE STORE-SIDE SWEEP: handoff.go's Consumer.Reap / +// Consumer.Run, which this package now exposes precisely so the two owners +// are driven together. See handoff.go's "WHO DRIVES THE REAPER" section. +// +// CLOCK 3 — THE DAST DEADLINE. audit_record.dast_deadline_seconds +// Owner of the FORMULA: record.ComputeDastDeadline — `scan_run.started_at + +// dast_deadline_seconds`, fixed at record.Sealer.BeginAudit from the +// configured inputs THIS file resolves. research/21 Recommendation §5 +// requires "a configurable `dast_deadline` (suggested default 4h = half the +// buffer window), after which `dast.status = timed_out` and the record +// seals regardless. Following the owner's no-hard-coding rule for triggers, +// this must be config, not a constant." +// Owner of the DUE-CHECK: record.Sealer.SealDastIfDeadlineDue. +// +// THIS FILE USED TO OWN THAT DUE-CHECK AND MUST NOT AGAIN. CRITIQUE O.4 +// blocker 2: the check ran against `Deadlines.DastDeadlineAt`, an EXPORTED +// field on a value the caller holds, so a tick handler could move clock 3 +// by plain assignment — the exact thing clock 2 shrugs off, because clock 2 +// is decided against a copy the Sealer keeps privately. Clock 3 now has the +// same substrate: `startedAt` and `dast_deadline_seconds` live in the +// Sealer, fixed at BeginAudit, and SealDastIfDeadlineDue compares against +// them. What THIS file carries is a DERIVED, ADVISORY instant used for +// scheduling and diagnostics, on a struct with no assignable field. +// +// WHAT "8 HOURS" IS. plan/00-SPINE.md S1 correction #5, verbatim: "'8 hours' +// is a claim timeout, not a deletion policy and not a confidentiality +// control." Nothing here deletes anything, nothing here is a retention +// guarantee, and nothing here is a security boundary. internal/record/SECRETS.md +// (R.9) is where the confidentiality posture lives. +// +// ONE ANCHOR, TWO OFFSETS. Clocks 2 and 3 are both anchored to +// `scan_run.started_at` and to nothing else. See the "Anchoring" section +// below; that choice is load-bearing and is the reason a late write cannot +// move either deadline. + +// --------------------------------------------------------------------------- +// ANCHORING — why `scan_run.started_at` and not `audit_record.created_at` +// --------------------------------------------------------------------------- +// +// research/21 §5 writes the field as `deadline_at : created_at + 8h`, with the +// gloss "anchored to scan START, never to last write". internal/store/schema.sql +// — the frozen interface — writes it as `deadline_at = scan_run.started_at + +// claim_timeout_seconds` and ALSO carries a separate `audit_record.created_at` +// column. Those are two different columns with two different meanings, and +// this is precisely the "two areas meaning different things by the same field +// name" class IMPLEMENTATION-PLAN.md §6 was convened over. +// +// THE ANCHOR IS `scan_run.started_at`. Resolution, not a preference: +// +// - `audit_record.created_at` is a WRITE timestamp — the moment the record +// row is first materialised, which is at or after the scan began and can +// be arbitrarily later on a loaded host. record.ComputeDeadline's doc +// states R.6's forbidden action outright: "Do not compute `deadline_at` +// from any write timestamp... Anchoring it to the last write makes the +// timeout unbounded for a chatty scan, which quietly defeats the reaper." +// Anchoring to created_at is a weaker form of the same mistake. +// - `scan_run.started_at` carries the schema comment "anvil/deadline.deadlineAt +// is computed from THIS, never last write", and record.AuditConfig.StartedAt +// rejects the zero time for the same reason. +// - So research/21's `created_at` and the schema's `scan_run.started_at` name +// the SAME intended instant — "scan start" — and the schema's spelling is +// the frozen one. This file uses the schema's spelling everywhere and does +// not introduce a third. +// +// THE DAST DEADLINE SHARES THAT ANCHOR. It would have been plausible to anchor +// clock 3 to "when the DAST worker actually started" — when the target was +// provisioned, or when the DAST half went record.HalfStatusRunning. That is +// wrong, and the failure is concrete: a daemon that queues the DAST job behind +// five hours of other work would place a 4h DAST deadline at t=9h, past an 8h +// claim deadline at t=8h. The DAST clock would never bind, the half would +// never be forced terminal, and the audit would reach record.StateExpired +// still holding an unsealed DAST half — the exact outcome dast_deadline exists +// to prevent. It is also a write-anchored clock wearing a different hat. +// +// So `dast_deadline` is the DAST half's SHARE OF THE CLAIM WINDOW, not a +// wall-clock budget for the probing engine. An engine-level timeout (how long +// ZAP itself may run once it starts) is a different, smaller thing that +// belongs to the DAST tier, and it is not this file's. + +// --------------------------------------------------------------------------- +// Constraint Resolution +// --------------------------------------------------------------------------- +// +// Three hard constraints collide with the 8-hour claim window. research/09's +// own Gaps section lists the collision as an out-of-scope lead it did not +// chase: "the 8-hour buffer retention vs the 6-hour GitHub-hosted job cap and +// 5-day self-hosted cap [S22] — a scan that outlives its buffer is a branch +// 18/24 correctness question." research/14 §6 records that two branches +// predicted the same failure independently "and neither got an answer." This +// block is the answer. Each constraint is named, then resolved. +// +// (a) THE 6-HOUR GITHUB-HOSTED JOB CAP binds the Action's own runtime ONLY. +// +// Constraint: "Actions run limits: 6 hours max per job on GitHub-hosted +// runners" (research/09, Rate limits and run limits; Table D "Max job time +// | 6 hours [S22]"). The arithmetic that makes it a collision: +// GitHubHostedJobCap (6h) < the default claim window (8h, +// record.DefaultClaimTimeoutSeconds). A GitHub-hosted job that waited for +// the claim window to close would be killed two hours before it did. +// Deadlines.ExceedsGitHubHostedJobCap reports that relation for a given +// policy; at the shipped defaults it is TRUE, and that is the point. +// +// Resolution: thin Action, fat daemon (research/09 Recommendation §3). The +// Action is "a trigger, not a scanner": it evaluates policy locally and +// either runs inline delta-SAST on the runner, or fires +// `repository_dispatch`/signed webhook at the user's daemon for anything +// involving DAST or a full scan. THE ACTION MUST NEVER BLOCK WAITING FOR +// SCAN COMPLETION — fire and return. Under that shape the cap binds only +// the dispatch, which takes seconds, and the collision dissolves: the two +// durations no longer measure the same interval. +// +// The obvious objection is that the default DAST deadline (4h) is BELOW the +// 6h cap, so a job could in principle block for a DAST scan. It cannot, for +// two independent reasons. First, `dast_deadline` is an upper bound on when +// the half is FORCED terminal, not a promise of when it completes, and it is +// measured from scan start — a dispatch that queues behind other work on the +// user's own hardware consumes it before probing begins. Second, and +// decisive even if the timing worked: research/21 §5 reason 1 — "Blocking +// spends the owner's own 8-hour budget... If SAST blocks and DAST takes 6 +// hours, the coding agent gets 2 hours instead of 8." Blocking converts the +// claim window into a scan window. The Action returns immediately whether or +// not the cap would have permitted otherwise. +// +// (b) THE 5-DAY SELF-HOSTED JOB CAP is moot, and is never relied upon. +// +// Constraint: "5 days max per job on self-hosted" (research/09, same +// source; SelfHostedJobCap below). It is the one cap that comfortably +// exceeds an 8h claim window, so it reads like an escape hatch: run the +// whole scan inside a self-hosted job and the collision disappears. +// +// Resolution: Anvil does not take that hatch, on grounds that have nothing +// to do with the clock. research/09 Risk #7 quotes GitHub directly: +// "Self-hosted runners should almost never be used for public repositories +// on GitHub, because any user can open pull requests against the repository +// and compromise the environment." research/09 adds that Anvil's runner is +// "a machine holding model weights and a security-findings database — an +// unusually attractive target." So: Anvil gates self-hosted runners on +// public repos loudly (the shipped Action's README reproduces GitHub's +// warning verbatim and documents restricting to private repos via runner +// groups — that enforcement is step O.8's, not this file's), and it NEVER +// treats a self-hosted runner as the DAST execution host regardless of +// repository visibility. DAST executes on the user's daemon, which is a +// separately installed artifact (plan/00-SPINE.md S9-AMENDED: `anvil-dast`, +// with "no network probing capability compiled in" to core `anvil`). +// +// Consequence for the clock: no Anvil deadline may ever be derived from +// SelfHostedJobCap. The constant exists below so this resolution can be +// stated in arithmetic and so a future reader cannot re-derive the hatch by +// accident; it is a platform fact, not an Anvil budget. +// +// (c) A ZAP FULL SCAN OUTRUNNING THE WINDOW is bounded by `dast_deadline`. +// +// Constraint: research/15 §"Failure mode: two engines, one buffer, eight +// hours" — "Nuclei on a static Go binary finishes fast; a ZAP full scan +// with AJAX spidering does not. If a scheduled ZAP full scan outruns the +// 8-hour buffer retention, the correlated artifact expires before the +// coding agent consumes it. Anvil must bound engine wall-clock explicitly." +// research/14 §"On B3" narrows it: "The requirement only breaks for ZAP +// full scans, browser crawls, and fuzzing campaigns." +// +// Resolution: clock 3. At the DAST deadline record.Sealer.SealDastIfDeadlineDue +// seals the DAST half with record.HalfStatusTimedOut — a frozen `anvil/status` token, +// one of the four record.TerminalHalfStatuses — regardless of how much of +// the attack surface was covered. record.DeriveDastStatus then maps that +// half status onto the audit-level `anvil/dastStatus`, which is +// record.DastStatusTimedOut for a target that actually booted, and a +// provenance-dominant value (record.DastStatusTargetBootFailed, +// record.DastStatusTargetUnreachable, record.DastStatusSkippedNoManifest) +// when the target never came up — the provenance rules deliberately run +// first, so "we ran out of clock" cannot mask "there was nothing to probe". +// Getting that precedence right is the derivation's job and this file does +// not restate it: writing `dast_status` directly here, rather than sealing +// the half and letting record.DeriveDastStatus run, would be a second +// definition of a derived field that is NOT NULL in the schema. +// +// Because the DAST deadline is a strict sub-interval of the claim window +// (Deadlines.DastDeadlineBinds), the forced seal lands BEFORE expiry, so the +// audit reaches record.StateBothSealed and the record is consumable — with a +// half that says "timed out", which is honest — instead of reaching +// record.StateExpired with the SAST half stranded. plan/00-SPINE.md S6's +// rationale generalises here: a half that ran out of clock must be +// distinguishable from one scanned clean, and record.DastStatusTimedOut is +// that distinction. Note that the timed-out half is TERMINAL BUT NOT +// READABLE: record.IsTerminalHalfStatus is true for it, record's read gate +// stays shut on it. Whether a consumer may read any half is answered by +// record.HalfReadGate and by nothing in this package — five previous authors +// re-derived that predicate locally and all five got it wrong. +// +// (d) RESIDUAL RISK — THE ORPHANED CHECK RUN. OPEN. NEEDS A DESIGN SPIKE. +// +// Not resolved here, and deliberately not assumed away. +// +// Once (a) is applied, the triggering Action fires `repository_dispatch` +// and returns success within seconds. Its check run then reports the +// DISPATCH, not the scan. A DAST scan running for hours on the user's +// daemon has NO GitHub check run tied to it at all: there is no job to +// attach to, the workflow run is already complete, and its `GITHUB_TOKEN` +// is revoked at job completion, so nothing on the GitHub side can even +// update a status afterwards. A reviewer looking at a PR sees a green +// check and concludes the scan passed, when in fact it has not started — +// which is research/09 Risk #4's failure mode ("reviewers systematically +// read [a blank checks list] as 'nothing to worry about'") in a second +// costume, and Risk #4 is filed as a safety failure, not a convenience one. +// +// The daemon must therefore INDEPENDENTLY create and update its own Checks +// API run (or Commit Status) rather than inheriting the Action's job +// status. Four things that spike must settle, none of which is decided: +// +// 1. Auth. Creating a check run needs `checks: write` on a GitHub App +// installation token (research/09 §4 already requires the App path for +// fix PRs). Whether the DAST daemon holds that installation token, and +// what else that token can then do, is an authorization-scope question +// — O.11's lane, and it must not be settled by whoever needs it first. +// 2. Which commit. research/09 Risk #12: "`repository_dispatch` runs on +// the default branch only... the branch must be passed in +// `client_payload`". So the head SHA the check run attaches to must +// travel in the dispatch payload and be validated on arrival; a check +// run created against the default-branch tip annotates the wrong +// commit. +// 3. Update cadence and the stall signal. GitHub does not time out an +// `in_progress` check run, so a daemon that dies leaves one running +// forever. The sweep that closes it must be driven by THESE deadlines +// — Deadlines.DeadlineAt() and Deadlines.DastDeadline() — and by nothing +// GitHub reports. +// 4. Rate limits against long scans, given research/09 Risk #13's +// 1,000 req/hr/repo ceiling on any `GITHUB_TOKEN` path. +// +// ONE INVARIANT IS NOT OPEN, and O.2 must hold it: the GitHub side is a +// PROJECTION of the record and can never be an input to it. A stalled, +// failed, rate-limited or rejected check-run update must not stall, block or +// corrupt the daemon-side record — it must not become a fourth clock, and it +// must never be able to move a deadline. That is now true BY CONSTRUCTION +// rather than by nobody having tried: Deadlines has no exported field, both +// instants are derived from `scan_run.started_at` at scan start, and both +// due-checks are the Sealer's against its own private copies. A future +// publisher handed an AuditRecord has nothing to assign to. If publishing to +// GitHub fails entirely, the audit still seals, still expires on schedule, +// and is still consumable locally. + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +// ErrInvalidDeadlinePolicy is the sentinel every DeadlinePolicy rejection +// wraps, so callers can branch with errors.Is while recovering the specifics +// with errors.As. +var ErrInvalidDeadlinePolicy = errors.New("scanctl: invalid deadline policy") + +// ErrZeroScanStart is returned when a Deadlines is asked for from the zero +// time. Both clocks are anchored to `scan_run.started_at`; a zero anchor +// would silently place the claim window in the year 1, and +// record.Sealer.BeginAudit rejects it for the same reason. +var ErrZeroScanStart = errors.New("scanctl: scan_run.started_at is the zero time") + +// PolicyError reports a refused DeadlinePolicy, naming the field and the +// observed value rather than merely the offence. +type PolicyError struct { + Field string // "claimTimeoutSeconds" | "dastDeadlineSeconds" | "startedAt" + Value string // the observed value, formatted + Reason string + Err error // ErrInvalidDeadlinePolicy or ErrZeroScanStart +} + +func (e *PolicyError) Error() string { + return fmt.Sprintf("scanctl: deadline policy field %s = %s is invalid: %s", + e.Field, e.Value, e.Reason) +} + +// Unwrap exposes the sentinel to errors.Is. +func (e *PolicyError) Unwrap() error { return e.Err } + +// --------------------------------------------------------------------------- +// Platform facts — NOT Anvil policy, NOT timeouts +// --------------------------------------------------------------------------- + +// GitHubHostedJobCap is GitHub's hard limit on a single job on a GitHub-hosted +// runner: "6 hours max per job on GitHub-hosted runners" (research/09, Rate +// limits and run limits, and Table D "Max job time | 6 hours [S22]"). +// +// IT IS A PLATFORM FACT, NOT AN ANVIL BUDGET. It exists so Constraint +// Resolution (a) can be stated as arithmetic and so +// Deadlines.ExceedsGitHubHostedJobCap can be computed. Nothing in Anvil may +// use it as a timeout, a deadline, or a default: the resolution is that the +// Action never blocks, which makes the cap irrelevant to Anvil's own clocks +// rather than a number Anvil has to fit inside. +const GitHubHostedJobCap = 6 * time.Hour + +// SelfHostedJobCap is GitHub's hard limit on a single job on a self-hosted +// runner: "5 days max per job on self-hosted" (research/09, same source). +// +// IT IS A PLATFORM FACT THAT ANVIL DELIBERATELY DOES NOT USE. See Constraint +// Resolution (b): the 5-day headroom would "solve" the clock collision by +// running the scan inside a self-hosted job, and Anvil refuses that shape on +// security grounds (research/09 Risk #7) independent of any deadline. This +// constant is declared so the refusal is legible and cannot be re-derived by +// accident, not so that anything can be measured against it. +const SelfHostedJobCap = 5 * 24 * time.Hour + +// --------------------------------------------------------------------------- +// DeadlinePolicy — the configured inputs to both scan-scoped clocks +// --------------------------------------------------------------------------- + +// DeadlinePolicy is the CONFIGURATION for clocks 2 and 3 of one audit: how +// long an unclaimed finding stays eligible, and how much of that window the +// DAST half gets before it is forced terminal. +// +// It is data, never a constant. plan/00-SPINE.md S1 makes "no hard-coded +// triggers" a hard constraint and research/21 §5 extends it to this value +// explicitly: "Following the owner's no-hard-coding rule for triggers, this +// must be config, not a constant." The zero DeadlinePolicy is meaningful and +// resolves to the documented defaults; it is not an error. +// +// WHERE THE VALUES COME FROM is not this file's business either. Trigger +// policy is `.anvil/policy.yml`, whose schema and search order are steps +// O.5/O.6; this type is the shape those values land in after parsing. Nothing +// here reads a file, names an event, or matches a ref. +type DeadlinePolicy struct { + // ClaimTimeoutSeconds is `audit_record.claim_timeout_seconds` — clock 2. + // Zero means record.DefaultClaimTimeoutSeconds (28800 = 8h); negative is + // rejected, matching the schema's ck_audit_record_claim_timeout_positive + // and record.Sealer.BeginAudit's own check. + // + // It is a CLAIM timeout. Not retention, not deletion, not + // confidentiality (plan/00-SPINE.md S1 correction #5). + ClaimTimeoutSeconds int + + // DastDeadlineSeconds is `audit_record.dast_deadline_seconds` — clock 3, + // INDEPENDENT of clock 2 in semantics though sharing its anchor. + // + // Nil means "use the derived default", DefaultDastDeadlineSeconds — half + // the resolved claim window, which is 4h at the 8h default, matching + // research/21 §5's "suggested default 4h = half the buffer window". It is + // derived rather than written down as 14400 so that an operator who + // configures a 2h claim window gets a 1h DAST deadline instead of a DAST + // clock that can never fire. + // + // Resolve forces this to nil when DastEnabled is false, matching + // record.AuditConfig.DastDeadlineSeconds ("Nil when DAST is disabled"). + // Non-nil and non-positive is rejected, matching + // ck_audit_record_dast_deadline_positive. + DastDeadlineSeconds *int + + // DastEnabled reports whether this installation has a DAST half at all. + // It is FALSE in the core `anvil` artifact: plan/00-SPINE.md S9-AMENDED + // splits `anvil-dast` into a separately installed artifact with the + // network-probing capability compiled in, so Tier S "simply does not + // install `anvil-dast`." + // + // When false there is no DAST clock to run: record.Sealer.BeginAudit + // immediately and terminally seals the DAST half as + // record.HalfStatusSkipped / record.DastStatusNotRun, and the audit + // reaches record.StateBothSealed the moment its SAST half seals. A DAST + // deadline over a half that never runs would be a timer that can only + // ever fire on nothing. + DastEnabled bool +} + +// DefaultDastDeadlineSeconds returns the DAST deadline derived from a resolved +// claim timeout: half of it, per research/21 §5's "default 4h = half the +// buffer window". At record.DefaultClaimTimeoutSeconds (28800) it returns +// 14400 — four hours. +// +// It is a FUNCTION rather than a constant on purpose. Writing 14400 down would +// silently decouple the two clocks the moment an operator changed the claim +// window, and a DAST deadline at or beyond the claim deadline never binds (see +// Deadlines.DastDeadlineBinds). The relation is the requirement; 4h is only +// what the relation evaluates to at the shipped default. +// +// The result is clamped to a minimum of 1 second because +// ck_audit_record_dast_deadline_positive requires > 0; a claim window short +// enough to hit that clamp is a test fixture, not a deployment. +func DefaultDastDeadlineSeconds(claimTimeoutSeconds int) int { + if half := claimTimeoutSeconds / 2; half > 0 { + return half + } + return 1 +} + +// Resolve fills in defaults, applies the DAST-disabled rule, validates, and +// returns a DeadlinePolicy whose fields are all concrete. +// +// It is the ONE place a DeadlinePolicy acquires its defaults. Resolve is +// idempotent: resolving an already-resolved policy returns it unchanged, so a +// caller that cannot remember whether it resolved may resolve again. +func (p DeadlinePolicy) Resolve() (DeadlinePolicy, error) { + out := DeadlinePolicy{DastEnabled: p.DastEnabled} + + out.ClaimTimeoutSeconds = p.ClaimTimeoutSeconds + if out.ClaimTimeoutSeconds == 0 { + out.ClaimTimeoutSeconds = record.DefaultClaimTimeoutSeconds + } + if out.ClaimTimeoutSeconds < 0 { + return DeadlinePolicy{}, &PolicyError{ + Field: "claimTimeoutSeconds", Value: fmt.Sprint(p.ClaimTimeoutSeconds), + Reason: "the schema requires > 0 (ck_audit_record_claim_timeout_positive)", + Err: ErrInvalidDeadlinePolicy, + } + } + + // VALIDATED BEFORE THE DastEnabled BRANCH, not after it. CRITIQUE O.4 + // finding O4-m5: this check used to live below the early return, so a + // policy carrying `dastDeadlineSeconds: -1` with DAST off resolved clean + // and the config error survived — invisibly — until the day somebody + // installed `anvil-dast`, at which point a scan that had been working + // started failing on a line nobody had touched. A value is either legal or + // it is not; whether this installation happens to read it today is a + // separate question from whether it is well-formed. + if p.DastDeadlineSeconds != nil && *p.DastDeadlineSeconds <= 0 { + return DeadlinePolicy{}, &PolicyError{ + Field: "dastDeadlineSeconds", Value: fmt.Sprint(*p.DastDeadlineSeconds), + Reason: "the schema requires NULL or > 0 (ck_audit_record_dast_deadline_positive)", + Err: ErrInvalidDeadlinePolicy, + } + } + + if !p.DastEnabled { + // No DAST half exists, so there is no clock 3. Left nil, matching + // record.AuditConfig.DastDeadlineSeconds's own contract. + return out, nil + } + + if p.DastDeadlineSeconds == nil { + derived := DefaultDastDeadlineSeconds(out.ClaimTimeoutSeconds) + out.DastDeadlineSeconds = &derived + return out, nil + } + configured := *p.DastDeadlineSeconds // already checked positive above + out.DastDeadlineSeconds = &configured + return out, nil +} + +// ClaimTimeout returns the resolved claim window as a Duration. It resolves +// first, so it is safe on a zero-valued policy; an invalid policy yields 0. +func (p DeadlinePolicy) ClaimTimeout() time.Duration { + r, err := p.Resolve() + if err != nil { + return 0 + } + return time.Duration(r.ClaimTimeoutSeconds) * time.Second +} + +// DastDeadline returns the resolved DAST budget and whether there is one at +// all. ok is false when DAST is disabled — the Tier S common case — and when +// the policy is invalid. +func (p DeadlinePolicy) DastDeadline() (time.Duration, bool) { + r, err := p.Resolve() + if err != nil || r.DastDeadlineSeconds == nil { + return 0, false + } + return time.Duration(*r.DastDeadlineSeconds) * time.Second, true +} + +// AuditConfig projects this policy plus an audit identity and a scan start +// onto the record.AuditConfig that record.Sealer.BeginAudit consumes. +// +// It exists so that no caller in this package ever populates a +// record.AuditConfig field by field. That is the shape in which the deadline +// fields could drift from the ones this file computes, and R.6 owns the +// resulting `audit_record` columns. +// +// BeginAudit computes `deadline_at` itself, once, via record.ComputeDeadline. +// This method does not pass a deadline; it passes the inputs. Deadlines.At +// computes the same instant for scheduling purposes, by calling the same +// record.ComputeDeadline — there is one formula, in record, and this package +// holds no copy of it. +func (p DeadlinePolicy) AuditConfig(auditID string, startedAt time.Time) (record.AuditConfig, error) { + r, err := p.Resolve() + if err != nil { + return record.AuditConfig{}, err + } + if startedAt.IsZero() { + return record.AuditConfig{}, &PolicyError{ + Field: "startedAt", Value: "0001-01-01T00:00:00Z", + Reason: "both clocks are anchored to scan_run.started_at and cannot be computed from the zero time", + Err: ErrZeroScanStart, + } + } + return record.AuditConfig{ + AuditID: auditID, + StartedAt: startedAt, + ClaimTimeoutSeconds: r.ClaimTimeoutSeconds, + DastEnabled: r.DastEnabled, + DastDeadlineSeconds: r.DastDeadlineSeconds, + }, nil +} + +// At fixes both scan-scoped clocks against one scan start. Call it ONCE, at +// scan start, alongside record.Sealer.BeginAudit; the returned Deadlines is +// immutable by construction and nothing in this package recomputes it. +func (p DeadlinePolicy) At(startedAt time.Time) (Deadlines, error) { + r, err := p.Resolve() + if err != nil { + return Deadlines{}, err + } + if startedAt.IsZero() { + return Deadlines{}, &PolicyError{ + Field: "startedAt", Value: "0001-01-01T00:00:00Z", + Reason: "both clocks are anchored to scan_run.started_at and cannot be computed from the zero time", + Err: ErrZeroScanStart, + } + } + + d := Deadlines{ + startedAt: startedAt, + deadlineAt: record.ComputeDeadline(startedAt, r.ClaimTimeoutSeconds), + claimTimeoutSeconds: r.ClaimTimeoutSeconds, + } + // ONE FORMULA PER CLOCK, AND BOTH LIVE IN record. Clock 3's is + // record.ComputeDastDeadline, the exact analogue of record.ComputeDeadline, + // and the same function record.Sealer.SealDastIfDeadlineDue decides against. + // Computing `startedAt + secs` here instead would be a second copy of a + // formula whose whole point is that the Sealer and every derived view agree + // to the nanosecond. + if at, ok := record.ComputeDastDeadline(startedAt, r.DastDeadlineSeconds); ok { + d.dastDeadlineAt = at + d.hasDastDeadline = true + d.dastDeadlineSeconds = *r.DastDeadlineSeconds + } + return d, nil +} + +// --------------------------------------------------------------------------- +// Deadlines — the two fixed instants, computed once at scan start +// --------------------------------------------------------------------------- + +// Deadlines is one audit's pair of scan-scoped deadline instants, fixed at +// scan start and never recomputed. +// +// THE ONLY WAY TO CHANGE A DEADLINE IS TO START A NEW SCAN. No seal, no write, +// no publication, no consumer read and no GitHub round-trip moves either +// instant. internal/record enforces the same rule on the durable side and has a +// test named for it — TestDeadlineUnchangedByLateSeal — and +// record.Sealer.BeginAudit refuses a second BeginAudit for the same audit id +// precisely because re-beginning would recompute `deadline_at`. This type +// agrees with that rule rather than restating it. +// +// # EVERY FIELD IS UNEXPORTED, AND THAT IS THE ENFORCEMENT +// +// The previous version of this type made the claim above in a doc comment and +// then exported all five fields. CRITIQUE O.4 blocker 2 is what that cost: the +// argument offered was that Deadlines "is a value … and there is no method on +// it that mutates anything", which is true of methods and irrelevant to fields. +// A tick handler moved clock 3 with one assignment, the forced DAST seal did +// not fire, and nothing anywhere reported it. +// +// So the fields are unexported and DeadlinePolicy.At is the only producer. +// A composite literal in another package cannot set them — that is a compile +// error, not a convention — and this package's own code has no reason to. The +// accessors below are the whole surface. There are no pointer fields either, so +// two copies of a Deadlines can never share a mutable instant; the struct is +// comparable with ==. +// +// # AND THE INSTANTS STILL DO NOT DECIDE ANYTHING +// +// Unassignable is not the same as authoritative. Both instants here are DERIVED +// COPIES carried for SCHEDULING (when should the daemon next wake) and for +// DIAGNOSTICS. Neither is consulted by a due-check: clock 2's is +// record.Sealer.ExpireIfDue in memory and handoff.Queue.ExpireClaimTimeouts in +// the store, and clock 3's is record.Sealer.SealDastIfDeadlineDue. All three +// decide against the Sealer's own private `startedAt` plus the offsets +// BeginAudit fixed, which is why probe P10 could not fool clock 2 and can no +// longer fool clock 3. +type Deadlines struct { + // startedAt is `scan_run.started_at`, the shared anchor. Kept so both + // deadlines are auditable arithmetic rather than opaque instants. + startedAt time.Time + + // deadlineAt is `audit_record.deadline_at` — clock 2 — as computed by + // record.ComputeDeadline. + deadlineAt time.Time + + // dastDeadlineAt is clock 3, as computed by record.ComputeDastDeadline. + // hasDastDeadline is false when DAST is disabled — the Tier S common case + // — in which case there is no DAST half to force terminal and no clock 3 + // at all. A bool rather than a *time.Time so the struct stays comparable + // and carries nothing a copy could share. + dastDeadlineAt time.Time + hasDastDeadline bool + + // claimTimeoutSeconds and dastDeadlineSeconds are the resolved offsets + // that produced the instants above, carried so a caller writing + // `audit_record` never has to reverse the subtraction. + // dastDeadlineSeconds is meaningful only when hasDastDeadline. + claimTimeoutSeconds int + dastDeadlineSeconds int +} + +// StartedAt is `scan_run.started_at`: the one anchor both clocks are measured +// from. The zero Deadlines returns the zero time, which is what an audit that +// never went through DeadlinePolicy.At has. +func (d Deadlines) StartedAt() time.Time { return d.startedAt } + +// DeadlineAt is clock 2's instant, `audit_record.deadline_at`. +// +// IT IS NOT THE AUTHORITY ON EXPIRY. record.Sealer.ExpireIfDue owns that in +// memory and handoff.Queue.ExpireClaimTimeouts owns it in the store, and this +// package deliberately offers no third predicate that would let a caller decide +// expiry from this value. +func (d Deadlines) DeadlineAt() time.Time { return d.deadlineAt } + +// ClaimTimeoutSeconds is the resolved offset behind DeadlineAt. +func (d Deadlines) ClaimTimeoutSeconds() int { return d.claimTimeoutSeconds } + +// DastDeadlineSeconds is the resolved offset behind clock 3, and whether there +// is one. ok is false exactly when DAST is disabled for this audit. +func (d Deadlines) DastDeadlineSeconds() (int, bool) { + if !d.hasDastDeadline { + return 0, false + } + return d.dastDeadlineSeconds, true +} + +// due is the single "has this instant arrived" comparison in this file. +// +// It is INCLUSIVE of the instant itself, which is the same comparison +// record.Sealer.ExpireIfDue makes (`if s.now().Before(a.deadlineAt) { return +// false }`) and the same one handoff.Queue.ExpireClaimTimeouts makes (`if +// now.Before(c.deadline) { skip }`). Writing it once here means this package +// cannot drift half a tick away from either of them. +func due(now, at time.Time) bool { return !now.Before(at) } + +// DastDeadline returns clock 3's instant and whether there is one. +// +// ok is false exactly when DAST is disabled for this audit. A caller that +// treats !ok as "the deadline has not arrived yet" has inverted the meaning: +// the correct reading is "this installation has no DAST half", and +// record.Sealer.BeginAudit has already sealed that half as +// record.HalfStatusSkipped. +func (d Deadlines) DastDeadline() (time.Time, bool) { + if !d.hasDastDeadline { + return time.Time{}, false + } + return d.dastDeadlineAt, true +} + +// THERE IS NO DastDeadlineElapsed, AND THERE MUST NOT BE ONE AGAIN. +// +// This type used to carry `DastDeadlineElapsed(now)` and to call it "THE ONE +// DUE-CHECK THIS PACKAGE OWNS". CRITIQUE O.4 blocker 2 found what that was +// worth: the predicate read `DastDeadlineAt`, an exported field on the caller's +// own copy, so clock 3 could be moved by assignment and the forced seal simply +// never fired — while clock 2, asked the same way, was unfoolable because +// record.Sealer.ExpireIfDue decides against a copy the Sealer keeps privately. +// +// The due-check now lives in the same place the authoritative inputs do: +// record.Sealer.SealDastIfDeadlineDue, the exact analogue of ExpireIfDue, +// deciding against the audit's own `startedAt + dast_deadline_seconds` as fixed +// at BeginAudit. It also performs the seal, so there is no window in which a +// caller has been told "due" and has not yet acted, and no second opinion about +// what "due" means. +// +// A predicate here would be a THIRD owner for a clock that has two (memory and +// store), over an input nobody re-reads. If a future author wants one, the +// question to answer first is what substrate it would decide against — +// deadlines.go has none, by design. + +// DastDeadlineBinds reports whether clock 3 fires strictly before clock 2 — +// the relation that makes a timed-out DAST half land as a SEAL rather than as +// an expiry. +// +// It is TRUE at the shipped defaults (4h < 8h) and true for any policy that +// takes DefaultDastDeadlineSeconds. It is FALSE when an operator configures a +// DAST deadline at or beyond the claim window, which the schema permits +// (ck_audit_record_dast_deadline_positive checks only positivity) and which +// this package therefore does not reject. +// +// NOT REJECTING IT IS A DELIBERATE CHOICE, and this is the argument. A policy +// layer that refuses a config the frozen schema accepts becomes a second, +// stricter definition of what a legal audit is — the exact defect class +// IMPLEMENTATION-PLAN.md §6 catalogues — and the operator who set it may have +// meant it. What such a policy costs is real and should be logged, not +// silently absorbed: the DAST half will never be forced terminal, so a +// never-terminating DAST run leaves the audit in record.StateSastSealed until +// clock 2 expires it, and the SAST findings are then lost to the claim window +// rather than handed over. Callers should surface a warning; the controller +// must not fail the scan over it. +func (d Deadlines) DastDeadlineBinds() bool { + at, ok := d.DastDeadline() + return ok && at.Before(d.deadlineAt) +} + +// ExceedsGitHubHostedJobCap reports whether the claim window is longer than +// GitHubHostedJobCap — the arithmetic behind Constraint Resolution (a). +// +// At the shipped defaults it is TRUE (8h > 6h), and that is not a +// misconfiguration to be corrected: it is the fact that forces the thin +// Action / fat daemon split. A GitHub-hosted job cannot outlive the claim +// window, so it must never try to; the Action fires and returns, and the two +// durations stop measuring the same interval. +// +// It is a DIAGNOSTIC. Nothing in Anvil may shorten a claim window to make this +// false, and nothing may branch on it to decide whether to block — the Action +// never blocks either way, for the independent reason in research/21 §5 reason +// 1 that blocking spends the owner's own claim budget. +func (d Deadlines) ExceedsGitHubHostedJobCap() bool { + return d.deadlineAt.Sub(d.startedAt) > GitHubHostedJobCap +} + +// RemainingClaimWindow reports how much of the claim window is left at now, +// clamped at zero once the window has closed. +// +// It is the budget input plan/00-SPINE.md S6's ordering rule needs — "re-cut +// the work queue on every version bump and reserve a configurable fraction +// (default 50%) of remaining budget for late DAST-confirmed arrivals" — and it +// is arithmetic, not a decision. A zero return does NOT by itself mean the +// audit has expired; record.Sealer.ExpireIfDue and +// handoff.Queue.ExpireClaimTimeouts decide that, and a live claim is never +// expired out from under its holder (research/08 §4 point 2, enforced in +// internal/handoff). +func (d Deadlines) RemainingClaimWindow(now time.Time) time.Duration { + if remaining := d.deadlineAt.Sub(now); remaining > 0 { + return remaining + } + return 0 +} + +// NextWake returns the earliest of the two deadline instants that has not yet +// arrived, for a daemon timer to sleep until. ok is false when both have +// arrived and there is nothing left to wait for. +// +// IT IS A SCHEDULING ANSWER, NOT A DECISION. Waking at the returned instant is +// what gives the controller the opportunity to act; what it then does is +// decided by the owners of each clock — record.Sealer.ExpireIfDue for clock 2 +// and record.Sealer.SealDastIfDeadlineDue for clock 3, both driven by O.2's +// EventKindTick. A caller +// that infers "the returned instant is the DAST deadline, therefore the DAST +// half is not yet timed out" has substituted a scheduling hint for a due-check +// and will be wrong whenever the timer fires late, which on a +// resource-governed host (research/21 §E) is routine. +func (d Deadlines) NextWake(now time.Time) (time.Time, bool) { + candidates := []time.Time{d.deadlineAt} + if at, ok := d.DastDeadline(); ok { + candidates = append(candidates, at) + } + + next := time.Time{} + found := false + for _, at := range candidates { + if at.IsZero() || due(now, at) { + continue + } + if !found || at.Before(next) { + next, found = at, true + } + } + return next, found +} diff --git a/internal/scanctl/handoff.go b/internal/scanctl/handoff.go new file mode 100644 index 0000000..e4b268a --- /dev/null +++ b/internal/scanctl/handoff.go @@ -0,0 +1,794 @@ +// The coding-agent claim path as the scan controller sees it (step O.3). +// +// # THIS FILE IS AN ADAPTER. IT OWNS NO TABLE, NO PROTOCOL AND NO STATE. +// +// plan/IMPLEMENTATION-PLAN.md §6 ruling G9 found the `handoff` table defined +// and created TWICE, in two migrations, with two Go APIs, and ruled: +// +// "Area 40 owns the table and the claim/lease protocol. […] O.3 no longer +// writes a migration; internal/scanctl/handoff.go becomes a thin adapter +// over internal/handoff." +// +// So everything load-bearing lives elsewhere and is CALLED from here: +// +// internal/store/schema.sql the ONE `handoff` table definition, +// including O.3's `consumption_class` +// (static_only | requires_dynamic_confirmation), +// which survived the merge intact +// internal/record/contract.go the thirteen frozen handoff.state literals +// handoff.Queue.AcquireLease the grant +// handoff.Queue.Claim the grant, narrowed to one fingerprint +// handoff.Queue.RenewLease the heartbeat +// handoff.Queue.ReleaseLease the disposition +// handoff.Queue.ReclaimExpired the crash path (clock 1) +// handoff.Queue.ExpireClaimTimeouts the claim-timeout sweep (clock 2, store side) +// handoff.Queue.Reap both sweeps, in the load-bearing order +// handoff.Queue.Run the sweep loop +// handoff.Queue.ReadPacket the gated result surface +// handoff.CheckTransition the state machine +// handoff.ExhaustedState "the attempt did not produce a validated fix" +// +// There is no SQL in this file, no second definition of the consumption gate, +// no second lease clock and no second reaper. CRITIQUE-02 then found a +// double-grant bug (F1) in the one implementation that does exist; a second +// implementation would not have been safer, it would have been a second place +// for that bug to hide. +// +// # WHAT THIS FILE ADDS, and it is only these three things +// +// 1. LeaseOptions / NewConsumer — the ONE number the scan controller owns +// that internal/handoff cannot know: the relation between the lease and +// the claim window. See "Two clocks" below. +// 2. Task — a handoff.Handle projected onto a value a coding agent may be +// handed. It carries the lease privately, so a Task cannot be forged and +// cannot be mistaken for a lease token, and it carries nothing that widens +// scope: plan/00-SPINE.md S7 grants "may act on this finding" and never +// merge authority, so there is no field here that could express one. +// 3. ConsumeOne — acquire, apply, dispose, exactly once per call, with the +// failure disposition chosen the same way the reaper chooses it. +// +// # TWO CLOCKS, AND THE ONE RELATION BETWEEN THEM +// +// `buffer.lease` (research/08 §4: 15–30 minutes, heartbeat-renewed, "never 8 +// hours") and `audit_record.claim_timeout_seconds` (deadlines.go clock 2, 8h +// by default) are different measurements with different owners. internal/handoff +// holds the first; deadlines.go holds the second. Neither can check the +// relation between them alone, and the relation is what makes the retry budget +// reachable: +// +// lease < claim window +// +// If a lease is as long as the claim window, ONE attempt consumes the whole +// window. A consumer that is OOM-killed at minute two holds the finding until +// the window closes, ReclaimExpired never gets to requeue it inside the +// window, `max_attempts` is unreachable, and the finding is swept to +// 'expired' having been attempted once. The retry the schema pays a column +// for silently stops existing. NewConsumer refuses that configuration rather +// than letting it be discovered as a lost finding months later. +// +// # RE-ENTRANCY AND IDEMPOTENCE ARE NOT IMPLEMENTED HERE EITHER +// +// The packet requires "reclaiming an expired lease and re-processing must be +// idempotent, keyed by (finding fingerprint, record version)". That property +// is already produced by two mechanisms in internal/handoff, and this file's +// job is to not break them and to make the key impossible to ignore: +// +// - The dead holder cannot land its work. Every mutation is a +// compare-and-swap on the exact (state='leased', claimed_by, +// lease_expires_at) triple, so an OOM-killed consumer that wakes up and +// reports success after its successor took over affects zero rows and gets +// handoff.ErrLeaseLost. Task carries that lease privately for precisely +// this reason: a caller cannot route around the CAS by rebuilding a handle. +// - The successor's work is recognisable as the SAME work. Task.IdempotencyKey +// is sha256(anvil/auditId ‖ fingerprint ‖ base commit SHA) — stable across +// crash and reclaim, and identical to the git trailer the coding agent +// writes — so a duplicate side effect is detectable by whoever applies it. +// ConsumeOne puts it in front of every applier. +// +// ConsumeOne deliberately does NOT retry internally. A retry inside one call +// would be a second attempt that `attempts` never counted, which is the one +// thing that could make the counter — and therefore the reaper's requeue rule +// — lie. +// +// # WHO DRIVES THE REAPER — clock 2 has two owners and they must run together +// +// CRITIQUE O.4 finding O4-M5: deadlines.go names two owners for clock 2's +// due-check — "record.Sealer.ExpireIfDue in memory, and +// handoff.Queue.ExpireClaimTimeouts against the store" — and O.2's tick drove +// the first while NOTHING IN THE TREE drove the second. Four comments named an +// owner with no call site anywhere. That is not a documentation gap; §6 ruling +// G10 catalogues the resulting divergence exactly: the controller marks an audit +// `expired` in memory while its `handoff` rows stay 'ready' and keep being +// leased, "so it is re-leased forever". +// +// This adapter is the only file in the tree that sees both clocks, so the wiring +// is here: +// +// Consumer.ReclaimExpired clock 1 only — lapsed leases, the crash path +// Consumer.Reap BOTH sweeps, leases first (handoff.Queue.Reap) +// Consumer.Run Reap on an interval until the context is cancelled +// +// A DAEMON MUST DRIVE Consumer.Run (or call Consumer.Reap on its own schedule). +// The in-memory half of clock 2 is driven by O.2's EventKindTick, on the +// schedule Controller.NextWake computes; the store half is driven here. Running +// only one of the two is the divergence above, and running the reaper's two +// sweeps out of order costs a crashed finding a whole extra interval — which is +// why Reap is delegated whole rather than re-composed from its two halves in +// this file. +// +// The interval is internal/handoff's business (handoff.DefaultReaperInterval, +// with research/08 §4's "must be <= ttl/8" written down at its definition), and +// this file does not acquire an opinion about it: Run passes it through. +// +// # WHY THERE IS NO READ GATE IN THIS FILE +// +// internal/record/sealing.go's header records FIVE authors re-deriving +// readability locally and all five getting an arm wrong. The defence is that +// there is exactly ONE predicate, record.HalfReadGate, and every other +// spelling is a thin wrapper over it. So this file adds no sixth spelling: +// +// - It never returns a half's results directly. The one result surface it +// exposes, Packet, delegates to handoff.Queue.ReadPacket, which R.7 gates +// with packetGate — "a cache of the payload cannot be less protected than +// the payload" (CRITIQUE-02 F5). +// - The producer side, where a half's findings leave a record for the queue, +// is Controller.Findings in statemachine.go, which routes through +// record.Sealer.ReadHalf — the gate, over a seal that package minted. It is +// not duplicated here. +// - Whether a finding's consumption gate is open is decided by ONE SQL +// expression in internal/handoff (consumptionGate), evaluated atomically +// with the grant. A Go re-check here would be a second definition that +// could disagree, and it could not be atomic with anything. +// +// (Free-floating file comment: deadlines.go carries the package doc.) + +package scanctl + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/handoff" + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +// The sentinels this file adds. There are only two, and both name a refusal +// internal/handoff has no opinion about because it cannot see a DeadlinePolicy. +// Every OTHER refusal a claim can produce is internal/handoff's own — +// handoff.ErrNoWork, handoff.ErrAlreadyClaimed, handoff.ErrNotEligible, +// handoff.ErrExhausted, handoff.ErrLeaseLost, handoff.ErrRecordVersionChanged, +// handoff.ErrNoDynamicEvidence, handoff.ErrIllegalTransition — and is returned +// UNWRAPPED, so a caller branching with errors.Is sees the owning package's +// answer rather than a re-spelling of it. A second refusal vocabulary over a +// frozen one is the same defect class as a second enum. +var ( + // ErrNoQueue: a Consumer was asked for without the handoff.Queue it + // adapts. There is no fallback and there must not be one — constructing a + // Queue here would be this file acquiring the protocol it exists not to + // own. + ErrNoQueue = errors.New("scanctl: a Consumer requires a *handoff.Queue") + + // ErrNoApplier: ConsumeOne was called with a nil Applier. It is refused + // BEFORE any lease is taken, so a mistake cannot burn an attempt. + ErrNoApplier = errors.New("scanctl: ConsumeOne requires a non-nil Applier") + + // ErrLeaseExceedsClaimWindow: `buffer.lease` is not shorter than + // `audit_record.claim_timeout_seconds`, so one attempt consumes the whole + // claim window and the retry budget is unreachable. See the file header. + ErrLeaseExceedsClaimWindow = errors.New("scanctl: lease is not shorter than the claim window") + + // ErrNonPositiveLease: a lease of zero or less. handoff.Options treats + // zero as "use handoff.DefaultLease", so a zero only reaches this check + // after LeaseOptions has already had its chance to fill it in — at which + // point it means the Queue was built with something else. + ErrNonPositiveLease = errors.New("scanctl: lease must be positive") +) + +// LeaseError reports a refused lease/claim-window relation, naming BOTH +// durations rather than merely the offence — the same shape as PolicyError and +// record.SealingError, for the same reason: an operator reading this needs to +// know which of the two numbers to change. +type LeaseError struct { + Lease time.Duration // buffer.lease, from handoff.Options + ClaimWindow time.Duration // DeadlinePolicy.ClaimTimeout() + Reason string + Err error // ErrLeaseExceedsClaimWindow or ErrNonPositiveLease +} + +func (e *LeaseError) Error() string { + return fmt.Sprintf("scanctl: lease %s against a claim window of %s is invalid: %s", + e.Lease, e.ClaimWindow, e.Reason) +} + +// Unwrap exposes the sentinel to errors.Is. +func (e *LeaseError) Unwrap() error { return e.Err } + +// --------------------------------------------------------------------------- +// The lease/claim-window relation +// --------------------------------------------------------------------------- + +// CheckLease is THE relation between the two clocks, written once. +// +// It is exported because the check is needed at two moments that are not the +// same moment: LeaseOptions runs it before a Queue exists, and NewConsumer +// runs it against a Queue somebody else may have built. One function, two call +// sites — the same discipline internal/handoff applies to its consumption +// gate. +// +// The comparison is strict. A lease EQUAL to the claim window is refused for +// the same reason a longer one is: the second attempt would begin exactly as +// the window closes, which is not a retry. +func CheckLease(lease time.Duration, policy DeadlinePolicy) error { + if lease <= 0 { + return &LeaseError{ + Lease: lease, ClaimWindow: policy.ClaimTimeout(), + Reason: "research/08 §4's buffer.lease is a positive duration; " + + "zero means 'use handoff.DefaultLease' only at handoff.Options, not here", + Err: ErrNonPositiveLease, + } + } + window := policy.ClaimTimeout() + if window <= 0 { + // The policy itself is what is wrong. Report it in the policy's own + // vocabulary rather than inventing a lease complaint about it. + if _, err := policy.Resolve(); err != nil { + return err + } + return &PolicyError{ + Field: "claimTimeoutSeconds", Value: fmt.Sprint(policy.ClaimTimeoutSeconds), + Reason: "the claim window resolved to zero, so no lease can be shorter than it", + Err: ErrInvalidDeadlinePolicy, + } + } + if lease >= window { + return &LeaseError{ + Lease: lease, ClaimWindow: window, + Reason: "one attempt would consume the whole claim window, so a crashed holder " + + "could never be reclaimed and re-attempted inside it and handoff.max_attempts " + + "would be unreachable", + Err: ErrLeaseExceedsClaimWindow, + } + } + return nil +} + +// LeaseOptions validates a handoff.Options against this controller's +// DeadlinePolicy and returns it with `Lease` made concrete. +// +// It fills in handoff.DefaultLease for a zero Lease — research/08 §4's +// 20 minutes, NOT a number invented here — and then checks the relation. Every +// other field (Clock, PacketDir, MaxAttempts) is passed through untouched: +// this function has no opinion about them and must not acquire one, because +// they are handoff.Options' business and a default written twice is a default +// that will drift. +// +// The caller then builds the Queue itself, with handoff.New. This function +// deliberately does not, so that there is exactly one constructor for a Queue +// and it is the owning package's. +func LeaseOptions(policy DeadlinePolicy, base handoff.Options) (handoff.Options, error) { + out := base + if out.Lease == 0 { + out.Lease = handoff.DefaultLease + } + if err := CheckLease(out.Lease, policy); err != nil { + return handoff.Options{}, err + } + return out, nil +} + +// --------------------------------------------------------------------------- +// Task — one lease, projected +// --------------------------------------------------------------------------- + +// Task is one finding a worker holds the lease on, in the shape the coding +// agent's side of the boundary sees. +// +// It is handoff.Handle with the lease made unreachable. Every exported field +// here is a fact the applier needs; the lease itself is not one of them, +// because a value the applier can copy, store and replay is precisely the +// thing that must not be able to authorise a write. Release, Renew and Packet +// take a Task and unwrap the lease internally, so the compare-and-swap that +// stops an OOM-killed consumer's late write cannot be routed around. +// +// plan/00-SPINE.md S7: a lease grants "may act on this finding" and nothing +// more. There is deliberately no field here naming a branch, a pull request, +// a merge, or any other finding — a Task cannot express widened scope because +// it has nowhere to put it. +type Task struct { + // Fingerprint is the anvil-fp/v1 digest, full 64 hex, never truncated. + Fingerprint string + + // IdempotencyKey is sha256(anvil/auditId ‖ fingerprint ‖ base commit SHA), + // computed by handoff.IdempotencyKey. It is STABLE ACROSS CRASH AND + // RECLAIM: the consumer that picks the finding up after a dead holder gets + // the same key the dead holder had, which is what makes a duplicate side + // effect recognisable. It is the value the coding agent writes into its git + // trailer, so the two sides of a crash can be matched up afterwards. + IdempotencyKey string + + // RecordVersion is audit_record.audit_version at the moment the lease was + // granted. With Fingerprint it is the (fingerprint, record version) key the + // packet requires re-processing to be idempotent under. A bump re-cuts the + // queue (plan/00-SPINE.md S6), and every mutation through this adapter + // re-checks it, so work against a stale version is refused rather than + // applied to a record that has moved. + RecordVersion int64 + + // ConsumptionClass is the gate the finding passed: static_only findings + // waited on the SAST half, requires_dynamic_confirmation findings waited on + // the DAST half (research/21 §5). It is the STORED value, authoritative + // over any derivation. + ConsumptionClass record.ConsumptionClass + + // DastStatus is the audit's DAST half status at claim time, carried so an + // applier can see that the half ended with no dynamic evidence rather than + // assume a clean dynamic scan. It is not advisory: releasing a + // requires_dynamic_confirmation finding as 'validated' without it is + // refused by internal/handoff with handoff.ErrNoDynamicEvidence. + DastStatus record.DastStatus + + // WorkerID is the lease holder. + WorkerID string + + // Attempt is this lease's ordinal — 1 for the first, 2 after one crash and + // one reclaim — and MaxAttempts is the budget. Attempts are counted at + // CLAIM time, because a consumer that is OOM-killed never gets to count + // anything itself. + Attempt int + MaxAttempts int + + // LeaseExpiresAt is when ReclaimExpired will presume this holder dead. It + // is the lease clock, NOT audit_record.deadline_at; see the file header on + // the two clocks. + LeaseExpiresAt time.Time + + // PacketPath is where the regenerable tmpfs packet lives, or "" when no + // PacketDir is configured. The packet is a cache: if it is missing, + // regenerate it from the store (research/08 §1). Its absence is not an + // error, and it is never the source of truth. + PacketPath string + + // lease is the handoff.Handle this Task projects. Unexported so a Task + // cannot be forged: only handoff.Queue.AcquireLease and .Claim mint one, + // and only the methods on Consumer can spend it. + lease handoff.Handle +} + +// HandoffID is the `handoff` row this Task holds, for logs and for a caller +// that must correlate with internal/handoff's own reports. +func (t Task) HandoffID() int64 { return t.lease.HandoffID } + +// Held reports whether this Task carries a lease at all. The zero Task does +// not, and every method that would spend one refuses it. +func (t Task) Held() bool { return t.lease.HandoffID != 0 && t.lease.WorkerID != "" } + +// AttemptsRemaining is how many further leases the finding may be granted +// after this one ends. Zero means this attempt is the last. +func (t Task) AttemptsRemaining() int { + if t.Attempt >= t.MaxAttempts { + return 0 + } + return t.MaxAttempts - t.Attempt +} + +// taskOf projects a Handle. It is the only place a Task is built, so no field +// can be populated from anywhere but the lease that was actually granted. +func taskOf(h handoff.Handle) Task { + return Task{ + Fingerprint: h.Fingerprint, + IdempotencyKey: h.IdempotencyKey, + RecordVersion: h.RecordVersion, + ConsumptionClass: h.ConsumptionClass, + DastStatus: h.DastStatus, + WorkerID: h.WorkerID, + Attempt: h.Attempt, + MaxAttempts: h.MaxAttempts, + LeaseExpiresAt: h.LeaseExpiresAt, + PacketPath: h.PacketPath, + lease: h, + } +} + +// errUnheld names the one mistake every Task-taking method has to refuse the +// same way: a zero or hand-built Task. +func errUnheld(op string) error { + return fmt.Errorf("scanctl: %s requires a Task from AcquireLease or Claim: %w", + op, handoff.ErrLeaseLost) +} + +// --------------------------------------------------------------------------- +// Consumer +// --------------------------------------------------------------------------- + +// Applier is the coding agent's side of one attempt. It receives the Task and +// returns the disposition to record. +// +// The returned state must be a legal successor of 'leased' — one of the +// eleven dispositions in the frozen thirteen-value handoff.state enum, or +// record.HandoffStateReady to hand the finding back for someone else. It is +// checked by handoff.CheckTransition before anything is written, so an +// out-of-order or out-of-vocabulary value never reaches the database. +// +// AN APPLIER MUST BE IDEMPOTENT IN Task.IdempotencyKey. It may be called for a +// key some earlier, dead attempt already applied — that is the crash path +// working as designed, not a defect — and the key is the only thing that makes +// the two calls recognisable as one unit of work. +type Applier func(ctx context.Context, t Task) (record.HandoffState, error) + +// Outcome is what one ConsumeOne call did. +type Outcome struct { + // Task is the lease that was granted. Its zero value means none was. + Task Task + + // State is the disposition actually written, or "" when nothing was. + State record.HandoffState + + // Applied reports whether the Applier ran to completion and chose State + // itself. False means State is the fallback disposition — see + // failureDisposition — or that nothing was written at all. + Applied bool + + // ApplyErr is the Applier's own error, preserved so a caller can inspect + // it with errors.As after ConsumeOne has already wrapped it. + ApplyErr error +} + +// Consumer is the scan controller's view of the claim path: a handoff.Queue +// plus the DeadlinePolicy whose claim window the lease must fit inside. +// +// It is safe for concurrent use because it holds no mutable state of its own; +// every mutation is one conditional UPDATE inside internal/handoff. +type Consumer struct { + q *handoff.Queue + policy DeadlinePolicy +} + +// NewConsumer adapts an existing handoff.Queue to a DeadlinePolicy. +// +// It re-runs CheckLease against the Queue's ACTUAL lease rather than trusting +// that LeaseOptions was used, because a Queue built directly with handoff.New +// is a perfectly ordinary thing to have and would otherwise skip the one check +// this file exists to make. +func NewConsumer(q *handoff.Queue, policy DeadlinePolicy) (*Consumer, error) { + if q == nil { + return nil, ErrNoQueue + } + resolved, err := policy.Resolve() + if err != nil { + return nil, err + } + if err := CheckLease(q.Lease(), resolved); err != nil { + return nil, err + } + return &Consumer{q: q, policy: resolved}, nil +} + +// Queue returns the adapted queue. +// +// It is exported because this file is an adapter and not a wall: enqueueing, +// disposal without a lease, the claim-timeout sweep and the state machine all +// live in internal/handoff and callers reach them THERE. Re-exporting each one +// through a method here would be the second API §6 G9 forbids, one delegation +// at a time. +func (c *Consumer) Queue() *handoff.Queue { return c.q } + +// Policy returns the resolved DeadlinePolicy this Consumer was built against. +func (c *Consumer) Policy() DeadlinePolicy { return c.policy } + +// Lease is the configured lease duration — handoff.Options.Lease, resolved. +func (c *Consumer) Lease() time.Duration { return c.q.Lease() } + +// RenewInterval is how often a holder should heartbeat: one third of the +// lease. +// +// DERIVATION, not a magic number. research/08 §4 specifies a lease renewed by +// heartbeat but no interval. The constraint is that a heartbeat may be missed +// — a GC pause, a slow database, one lost scheduler quantum — without the +// lease lapsing under a holder that is demonstrably alive. At lease/3 two +// consecutive heartbeats can be missed and a third still lands before +// lease_expires_at. At lease/2 a single miss leaves no margin, and any +// interval below lease/3 buys margin only by spending writes on a queue whose +// whole design avoids per-finding write storms (research/21 §5). +// +// It is a floor of one heartbeat per lease: a lease shorter than 3ns is a test +// fixture, not a deployment, and returning zero there would be an infinite +// heartbeat loop. +func (c *Consumer) RenewInterval() time.Duration { + if d := c.q.Lease() / 3; d > 0 { + return d + } + return c.q.Lease() +} + +// AcquireLease takes the lease on the oldest claimable finding and returns it +// as a Task. It is handoff.Queue.AcquireLease; the name is deliberately the +// same, because it is the same operation and a synonym would be the beginning +// of a second vocabulary. +// +// handoff.ErrNoWork means idle, not failure: nothing is ready, or nothing +// ready has passed its consumption gate. A requires_dynamic_confirmation +// finding whose DAST half has not reached a terminal state is exactly that +// case, and it is decided by internal/handoff's SQL gate, atomically with the +// grant — not by a check in this file. +func (c *Consumer) AcquireLease(workerID string) (Task, error) { + return c.AcquireLeaseContext(context.Background(), workerID) +} + +// AcquireLeaseContext is AcquireLease with a caller-supplied context. +func (c *Consumer) AcquireLeaseContext(ctx context.Context, workerID string) (Task, error) { + h, err := c.q.AcquireLeaseContext(ctx, workerID) + if err != nil { + return Task{}, err + } + return taskOf(h), nil +} + +// Claim takes the lease on one named finding: AcquireLease narrowed to a +// fingerprint. It is handoff.Queue.Claim. +// +// Exactly one concurrent caller wins; every loser gets +// handoff.ErrAlreadyClaimed. +func (c *Consumer) Claim(fingerprint, workerID string) (Task, error) { + return c.ClaimContext(context.Background(), fingerprint, workerID) +} + +// ClaimContext is Claim with a caller-supplied context. +func (c *Consumer) ClaimContext(ctx context.Context, fingerprint, workerID string) (Task, error) { + h, err := c.q.ClaimContext(ctx, fingerprint, workerID) + if err != nil { + return Task{}, err + } + return taskOf(h), nil +} + +// RenewLease is the heartbeat. It returns a FRESH Task; the old one is dead +// and must be discarded, exactly as with the handoff.Handle underneath. +// +// It refuses with handoff.ErrLeaseLost when the lease is no longer this +// worker's on this exact grant — the crash-and-reclaim case — and with +// handoff.ErrRecordVersionChanged when the audit version moved underneath. +func (c *Consumer) RenewLease(t Task) (Task, error) { + return c.RenewLeaseContext(context.Background(), t) +} + +// RenewLeaseContext is RenewLease with a caller-supplied context. +func (c *Consumer) RenewLeaseContext(ctx context.Context, t Task) (Task, error) { + if !t.Held() { + return Task{}, errUnheld("RenewLease") + } + h, err := c.q.RenewLeaseContext(ctx, t.lease) + if err != nil { + return Task{}, err + } + return taskOf(h), nil +} + +// ReleaseLease ends one attempt and records its disposition. +// +// The transition is checked by handoff.CheckTransition; 'validated' on a +// requires_dynamic_confirmation finding additionally requires the DAST half to +// have produced a reproduction (handoff.ErrNoDynamicEvidence, plan/00-SPINE.md +// S7). Neither rule is restated here, because a restated rule is a rule with +// two versions. +func (c *Consumer) ReleaseLease(t Task, to record.HandoffState) error { + return c.ReleaseLeaseContext(context.Background(), t, to) +} + +// ReleaseLeaseContext is ReleaseLease with a caller-supplied context. +func (c *Consumer) ReleaseLeaseContext(ctx context.Context, t Task, to record.HandoffState) error { + if !t.Held() { + return errUnheld("ReleaseLease") + } + return c.q.ReleaseLeaseContext(ctx, t.lease, to) +} + +// ReclaimExpired sweeps lapsed leases: the crash path. A holder that was +// OOM-killed loses its finding back to the ready set if an attempt remains, +// and to handoff.ExhaustedState if none does. +// +// It is handoff.Queue.ReclaimExpired and nothing else — there is no second +// reaper, no second expiry rule and no second exhaustion mapping in this +// package. The report is returned rather than logged because research/08 §4 +// point 4 asks for the expiry rate to be alertable: "a nonzero expired rate is +// the load signal that the coding agent is undersized relative to detector +// throughput." +func (c *Consumer) ReclaimExpired() (handoff.ReapReport, error) { + return c.q.ReclaimExpiredContext(context.Background()) +} + +// ReclaimExpiredContext is ReclaimExpired with a caller-supplied context. +func (c *Consumer) ReclaimExpiredContext(ctx context.Context) (handoff.ReapReport, error) { + return c.q.ReclaimExpiredContext(ctx) +} + +// ExpireClaimTimeouts sweeps findings whose audit's claim window has closed: +// clock 2, against the store. +// +// IT IS THE STORE-SIDE HALF OF A DUE-CHECK O.2 ALREADY DRIVES IN MEMORY. Prefer +// Reap, which runs it in the right order relative to the lease sweep; this +// exists so the two owners deadlines.go names are both REACHABLE from the one +// file that sees both clocks. Before CRITIQUE O.4 finding O4-M5 it was named in +// four comments here and called from nowhere in the tree. +func (c *Consumer) ExpireClaimTimeouts() (handoff.ReapReport, error) { + return c.q.ExpireClaimTimeoutsContext(context.Background()) +} + +// ExpireClaimTimeoutsContext is ExpireClaimTimeouts with a caller-supplied +// context. +func (c *Consumer) ExpireClaimTimeoutsContext(ctx context.Context) (handoff.ReapReport, error) { + return c.q.ExpireClaimTimeoutsContext(ctx) +} + +// Reap runs BOTH sweeps — lapsed leases (clock 1) then closed claim windows +// (clock 2) — and is what a daemon should call if it is not calling Run. +// +// It is handoff.Queue.Reap, delegated whole rather than re-composed here, and +// the ORDER is why. reaper.go: "A finding whose holder crashed AND whose audit +// deadline has passed must first be reclaimed out of 'leased' — the lease sweep +// is the only thing allowed to touch a leased row — and only then can the +// claim-timeout sweep see it as 'ready' and expire it." Composing the two calls +// in this file would be a second copy of that ordering rule, in the file whose +// entire premise is that it holds no second copy of anything. +func (c *Consumer) Reap() (handoff.ReapReport, error) { + return c.q.ReapContext(context.Background()) +} + +// ReapContext is Reap with a caller-supplied context. +func (c *Consumer) ReapContext(ctx context.Context) (handoff.ReapReport, error) { + return c.q.ReapContext(ctx) +} + +// Run drives Reap every interval until ctx is cancelled, handing each report to +// observe. It returns ctx.Err(). +// +// THIS IS THE CALL THAT WAS MISSING. deadlines.go names two owners for clock +// 2's due-check; Controller's EventKindTick drives the in-memory one and this +// drives the durable one. A deployment that runs only the first marks audits +// `expired` in memory while their `handoff` rows stay 'ready' and keep being +// leased — §6 ruling G10's exact shape. Start this alongside whatever loop +// consumes Controller.NextWake. +// +// interval <= 0 means handoff.DefaultReaperInterval. The bound on that value +// (research/08 §4: "must be <= ttl/8") belongs to internal/handoff and is stated +// at its definition; this adapter passes the number through and has no opinion +// about it, exactly as LeaseOptions passes handoff.Options' other fields. +// +// observe may be nil, and a sweep error does not stop the loop. Cancellation is +// not reported as a sweep error — see handoff.Queue.Run, which found that the +// hard way under CI's race detector. +func (c *Consumer) Run(ctx context.Context, interval time.Duration, observe func(handoff.ReapReport, error)) error { + return c.q.Run(ctx, interval, observe) +} + +// Packet returns the finding's regenerable packet bytes to the lease holder. +// +// This is the ONE result-bearing surface this file exposes, and it does not +// decide anything: handoff.Queue.ReadPacket re-asserts R.6's read gate at the +// packet — the lease must still be this Task's, the record version must not +// have moved, and the audit's consumption gate must still be open. See +// packetGate in internal/handoff, and CRITIQUE-02 F5 for what happened when +// those bytes were reachable without it. +// +// A missing packet is reported with os.ErrNotExist so the caller can +// regenerate from the store; the packet is a cache and its absence is not an +// error in itself. +func (c *Consumer) Packet(t Task) ([]byte, error) { + return c.PacketContext(context.Background(), t) +} + +// PacketContext is Packet with a caller-supplied context. +func (c *Consumer) PacketContext(ctx context.Context, t Task) ([]byte, error) { + if !t.Held() { + return nil, errUnheld("Packet") + } + return c.q.ReadPacketContext(ctx, t.lease) +} + +// WritePacket materialises the packet for a finding this worker holds, through +// the same gate. It is handoff.Queue.WritePacket. +func (c *Consumer) WritePacket(t Task, data []byte) (string, error) { + return c.WritePacketContext(context.Background(), t, data) +} + +// WritePacketContext is WritePacket with a caller-supplied context. +func (c *Consumer) WritePacketContext(ctx context.Context, t Task, data []byte) (string, error) { + if !t.Held() { + return "", errUnheld("WritePacket") + } + return c.q.WritePacketContext(ctx, t.lease, data) +} + +// --------------------------------------------------------------------------- +// ConsumeOne +// --------------------------------------------------------------------------- + +// failureDisposition is where an attempt lands when the Applier returns an +// error, i.e. when the consumer did not decide anything itself. +// +// IT IS THE REAPER'S RULE, APPLIED TO A SECOND TRIGGER, not a second rule. +// handoff.ReclaimExpired maps a lapsed lease to 'ready' when an attempt +// remains and to handoff.ExhaustedState when none does; a failed applier is +// the same situation reached by a different route — an attempt started, an +// attempt ended, nothing decided — so it maps the same way. Two triggers, one +// policy. +// +// What it must NOT do is invent a verdict. record.HandoffStateFailedValidation +// asserts that a fix failed validation and 'false_positive' asserts the +// finding was wrong; an applier that returned an error asserted neither, and +// this file has no standing to assert them on its behalf. 'ready' asserts +// nothing, and the attempt counter — incremented at claim time, so a crashed +// consumer cannot dodge it — is what stops the finding looping forever. +// +// handoff.ExhaustedState is referenced, never re-picked. internal/handoff +// chose record.HandoffStateFailedValidation for the exhausted case and gave +// its reasons; naming the constant here means the two can never disagree. +func failureDisposition(t Task) record.HandoffState { + if t.AttemptsRemaining() == 0 { + return handoff.ExhaustedState + } + return record.HandoffStateReady +} + +// ConsumeOne performs exactly one attempt: acquire a lease, run apply, record +// the disposition. +// +// It returns handoff.ErrNoWork when nothing is claimable, which is idle and +// not a failure. It does NOT retry, does not loop, and does not renew the +// lease — an applier that may outlive c.Lease() must call RenewLease itself, +// every c.RenewInterval(). A retry inside one call would be an attempt the +// `attempts` counter never saw, and that counter is the only measurement a +// crashed consumer can be judged by. +// +// The error returned is non-nil whenever anything went wrong, INCLUDING when +// the Applier failed but the fallback disposition was written successfully. +// Swallowing an applier's error because the protocol recovered from it is how +// a consumer that fails on every finding looks healthy. Outcome carries the +// detail either way, and Outcome.ApplyErr is wrapped, so errors.Is and +// errors.As against the applier's own sentinels keep working. +// +// THE LEASE IS LEFT HELD if the applier's chosen disposition is refused — +// an illegal transition, or 'validated' on a requires_dynamic_confirmation +// finding with no dynamic evidence (handoff.ErrNoDynamicEvidence). That is +// deliberate. The alternatives are to write some other disposition, which +// would mean this file overruling the applier's verdict with one it invented, +// or to swallow the refusal. Instead the caller gets the refusal and a Task +// that still holds its lease: it may release with a legal disposition, or drop +// it and let ReclaimExpired requeue it at the lease deadline. +func (c *Consumer) ConsumeOne(ctx context.Context, workerID string, apply Applier) (Outcome, error) { + if apply == nil { + return Outcome{}, ErrNoApplier + } + + t, err := c.AcquireLeaseContext(ctx, workerID) + if err != nil { + return Outcome{}, err + } + + to, applyErr := apply(ctx, t) + if applyErr != nil { + fallback := failureDisposition(t) + out := Outcome{Task: t, ApplyErr: applyErr} + if relErr := c.ReleaseLeaseContext(ctx, t, fallback); relErr != nil { + return out, fmt.Errorf( + "scanctl: applier for %s failed and the lease could not be released as %s: %w", + t.Fingerprint, fallback, relErr) + } + out.State = fallback + return out, fmt.Errorf("scanctl: applier for %s failed, released as %s: %w", + t.Fingerprint, fallback, applyErr) + } + + if err := c.ReleaseLeaseContext(ctx, t, to); err != nil { + // The lease is still held; see the doc comment. Applied is true + // because the applier DID run and DID choose — what failed is the + // recording of its choice, and conflating the two would hide which. + return Outcome{Task: t, Applied: true}, err + } + return Outcome{Task: t, State: to, Applied: true}, nil +} diff --git a/internal/scanctl/handoff_test.go b/internal/scanctl/handoff_test.go new file mode 100644 index 0000000..d1bc74d --- /dev/null +++ b/internal/scanctl/handoff_test.go @@ -0,0 +1,1275 @@ +package scanctl + +import ( + "context" + "database/sql" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "maps" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/handoff" + "github.com/Susquehanna-Syntax/Anvil/internal/record" + "github.com/Susquehanna-Syntax/Anvil/internal/store" + + _ "modernc.org/sqlite" // cgo-free driver, plan/00-SPINE.md S12 +) + +// --------------------------------------------------------------------------- +// Fixture +// +// The table under test is internal/store/schema.sql, applied through R.5's +// real migration path. Never a hand-copied DDL: a second copy of a frozen +// interface is the defect §6 G9 exists to prevent, and this step in particular +// was ruled out of writing its own migration. A fixture that invented a +// `handoff` table would prove nothing about the shipped one, and would prove +// it while demonstrating the exact sin. +// --------------------------------------------------------------------------- + +// hfTime is the on-disk timestamp format. internal/handoff writes this shape +// and accepts any RFC 3339 spelling on read; the test writes the same one so a +// fixture row is indistinguishable from a production one. +const hfTime = "2006-01-02T15:04:05.000000000Z" + +func hfFormat(t time.Time) string { return t.UTC().Format(hfTime) } + +// hfClock drives the lease clock independently of wall time. Every expiry +// assertion below advances it explicitly; nothing sleeps. +type hfClock struct{ at time.Time } + +func newHFClock() *hfClock { return &hfClock{at: baseTime} } + +func (c *hfClock) Now() time.Time { return c.at } + +func (c *hfClock) advance(d time.Duration) { c.at = c.at.Add(d) } + +type hfFixture struct { + t *testing.T + db *sql.DB + q *handoff.Queue + c *Consumer + clock *hfClock + targetID int64 +} + +// hfPolicy is the shipped default claim window: 8 hours, DAST installed. +func hfPolicy() DeadlinePolicy { return DeadlinePolicy{DastEnabled: true} } + +// newHFFixture builds an on-disk store, migrates it, and returns a Consumer +// over it. On-disk rather than :memory: because the queue's atomicity argument +// is about real separate connections, and because store.Migrate's own ledger +// wants a durable file. +func newHFFixture(t *testing.T, opts handoff.Options) *hfFixture { + t.Helper() + + dir := t.TempDir() + dbPath := filepath.ToSlash(filepath.Join(dir, "anvil.db")) + dsn := "file:" + strings.ReplaceAll(dbPath, " ", "%20") + + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)" + + "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := store.Migrate(context.Background(), db, ""); err != nil { + t.Fatalf("store.Migrate: %v", err) + } + + clock := newHFClock() + opts.Clock = clock.Now + if opts.PacketDir == "" { + opts.PacketDir = filepath.Join(dir, "packets") + } + + resolved, err := LeaseOptions(hfPolicy(), opts) + if err != nil { + t.Fatalf("LeaseOptions: %v", err) + } + q, err := handoff.New(db, resolved) + if err != nil { + t.Fatalf("handoff.New: %v", err) + } + c, err := NewConsumer(q, hfPolicy()) + if err != nil { + t.Fatalf("NewConsumer: %v", err) + } + + f := &hfFixture{t: t, db: db, q: q, c: c, clock: clock} + + res, err := db.Exec(`INSERT INTO target (kind, locator) VALUES (?, ?)`, + "repo", "https://example.invalid/repo.git") + if err != nil { + t.Fatalf("insert target: %v", err) + } + if f.targetID, err = res.LastInsertId(); err != nil { + t.Fatalf("target id: %v", err) + } + return f +} + +// hfFingerprint returns a distinct, well-formed 64-hex anvil-fp/v1 digest. +func hfFingerprint(n int) string { return strings.Repeat(fmt.Sprintf("%02x", n%256), 32) } + +// newAudit inserts a scan_run and its audit_record with the lifecycle values +// the test needs. deadline_at is supplied rather than derived: R.6 computes it +// once and this package only reads it. +func (f *hfFixture) newAudit(state record.State, sast record.HalfStatus, dast record.DastStatus) int64 { + f.t.Helper() + + res, err := f.db.Exec( + `INSERT INTO scan_run (target_id, started_at, ruleset_version, status, commit_sha) + VALUES (?, ?, ?, ?, ?)`, + f.targetID, hfFormat(f.clock.Now()), "anvil-rules/v1", + string(record.ScanRunStatusRunning), + "9f1c0de9f1c0de9f1c0de9f1c0de9f1c0de9f1c0") + if err != nil { + f.t.Fatalf("insert scan_run: %v", err) + } + scanRunID, err := res.LastInsertId() + if err != nil { + f.t.Fatalf("scan_run id: %v", err) + } + + var sastStatus any + if sast != "" { + sastStatus = string(sast) + } + deadline := f.clock.Now().Add(hfPolicy().ClaimTimeout()) + res, err = f.db.Exec( + `INSERT INTO audit_record + (scan_run_id, schema_version, state, sast_status, dast_status, + target_provenance, deadline_at, payload_sha256, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + scanRunID, "anvil/1", string(state), sastStatus, string(dast), + string(record.TargetProvenanceBootedClean), hfFormat(deadline), + strings.Repeat("b", 64), hfFormat(f.clock.Now())) + if err != nil { + f.t.Fatalf("insert audit_record: %v", err) + } + auditRecordID, err := res.LastInsertId() + if err != nil { + f.t.Fatalf("audit_record id: %v", err) + } + return auditRecordID +} + +// sealedAudit is the common case: both halves sealed, DAST clean. +func (f *hfFixture) sealedAudit() int64 { + f.t.Helper() + return f.newAudit(record.StateBothSealed, record.HalfStatusSealed, record.DastStatusCompletedClean) +} + +// setAudit moves an existing audit's lifecycle columns, so a test can watch a +// gate open rather than only observing it open. +func (f *hfFixture) setAudit(auditRecordID int64, state record.State, sast record.HalfStatus, dast record.DastStatus) { + f.t.Helper() + if _, err := f.db.Exec( + `UPDATE audit_record SET state = ?, sast_status = ?, dast_status = ? WHERE audit_record_id = ?`, + string(state), string(sast), string(dast), auditRecordID); err != nil { + f.t.Fatalf("update audit_record: %v", err) + } +} + +func (f *hfFixture) newFinding(fingerprint string) int64 { + f.t.Helper() + res, err := f.db.Exec( + `INSERT INTO finding + (target_id, fingerprint, detector, evidence_class, rule_id, severity, title, + state, remediable_by_agent, first_seen_scan, first_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT MAX(scan_run_id) FROM scan_run), ?)`, + f.targetID, fingerprint, string(record.DetectorKindSast), + string(record.EvidenceClassSastStaticOnly), "anvil.py.sqli/v3", "high", + "SQL injection", string(record.FindingStateOpen), 1, hfFormat(f.clock.Now())) + if err != nil { + f.t.Fatalf("insert finding: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + f.t.Fatalf("finding id: %v", err) + } + return id +} + +// hfAuditUUID is `anvil/auditId` — a separate identity from the rowid, because +// handoff.IdempotencyKey hashes THIS (CRITIQUE-02 F7). +func hfAuditUUID(auditRecordID int64) string { + return fmt.Sprintf("11111111-2222-4333-8444-%012d", auditRecordID) +} + +// enqueue seeds one ready finding through internal/handoff's own Enqueue. +// There is no scanctl-side enqueue and there must not be one: the producer +// path lives with whoever holds the rowids. +func (f *hfFixture) enqueue(n int, class record.ConsumptionClass, auditRecordID int64, maxAttempts int) (string, handoff.Row) { + f.t.Helper() + fingerprint := hfFingerprint(n) + row, err := f.q.Enqueue(handoff.EnqueueRequest{ + FindingID: f.newFinding(fingerprint), + AuditRecordID: auditRecordID, + AuditID: hfAuditUUID(auditRecordID), + Fingerprint: fingerprint, + ConsumptionClass: class, + MaxAttempts: maxAttempts, + }) + if err != nil { + f.t.Fatalf("Enqueue: %v", err) + } + return fingerprint, row +} + +func (f *hfFixture) rowState(handoffID int64) record.HandoffState { + f.t.Helper() + row, err := f.q.Get(handoffID) + if err != nil { + f.t.Fatalf("Get(%d): %v", handoffID, err) + } + return row.State +} + +func (f *hfFixture) row(handoffID int64) handoff.Row { + f.t.Helper() + row, err := f.q.Get(handoffID) + if err != nil { + f.t.Fatalf("Get(%d): %v", handoffID, err) + } + return row +} + +// --------------------------------------------------------------------------- +// The lease/claim-window relation +// --------------------------------------------------------------------------- + +func TestCheckLeaseAgainstTheClaimWindow(t *testing.T) { + eightHours := DeadlinePolicy{DastEnabled: true} + oneHour := DeadlinePolicy{ClaimTimeoutSeconds: 3600} + + cases := []struct { + name string + lease time.Duration + policy DeadlinePolicy + want error + }{ + {"the shipped defaults", handoff.DefaultLease, eightHours, nil}, + {"a short lease in a short window", time.Minute, oneHour, nil}, + {"one second under the window", time.Hour - time.Second, oneHour, nil}, + {"exactly the window is not a retry", time.Hour, oneHour, ErrLeaseExceedsClaimWindow}, + {"longer than the window", 9 * time.Hour, eightHours, ErrLeaseExceedsClaimWindow}, + {"zero after Options had its chance", 0, eightHours, ErrNonPositiveLease}, + {"negative", -time.Minute, eightHours, ErrNonPositiveLease}, + {"an invalid policy is reported as a policy error", + time.Minute, DeadlinePolicy{ClaimTimeoutSeconds: -1}, ErrInvalidDeadlinePolicy}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := CheckLease(tc.lease, tc.policy) + if tc.want == nil { + if err != nil { + t.Fatalf("CheckLease(%s) = %v, want nil", tc.lease, err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("CheckLease(%s) = %v, want %v", tc.lease, err, tc.want) + } + }) + } +} + +// The refusal must name BOTH durations. An operator told only "invalid lease" +// cannot tell which of the two numbers to change. +func TestLeaseErrorNamesBothClocks(t *testing.T) { + err := CheckLease(9*time.Hour, hfPolicy()) + var le *LeaseError + if !errors.As(err, &le) { + t.Fatalf("CheckLease error = %T, want *LeaseError", err) + } + if le.Lease != 9*time.Hour { + t.Errorf("LeaseError.Lease = %s, want 9h", le.Lease) + } + if le.ClaimWindow != 8*time.Hour { + t.Errorf("LeaseError.ClaimWindow = %s, want 8h", le.ClaimWindow) + } + for _, want := range []string{"9h", "8h"} { + if !strings.Contains(le.Error(), want) { + t.Errorf("LeaseError.Error() = %q, want it to mention %s", le.Error(), want) + } + } +} + +func TestLeaseOptionsFillsTheDefaultAndPassesEverythingElseThrough(t *testing.T) { + clock := newHFClock() + base := handoff.Options{PacketDir: "/run/anvil", MaxAttempts: 7, Clock: clock.Now} + + got, err := LeaseOptions(hfPolicy(), base) + if err != nil { + t.Fatalf("LeaseOptions: %v", err) + } + if got.Lease != handoff.DefaultLease { + t.Errorf("Lease = %s, want handoff.DefaultLease (%s)", got.Lease, handoff.DefaultLease) + } + if got.PacketDir != base.PacketDir { + t.Errorf("PacketDir = %q, want %q", got.PacketDir, base.PacketDir) + } + if got.MaxAttempts != base.MaxAttempts { + t.Errorf("MaxAttempts = %d, want %d", got.MaxAttempts, base.MaxAttempts) + } + if got.Clock == nil || !got.Clock().Equal(clock.Now()) { + t.Error("Clock was not passed through") + } + + if _, err := LeaseOptions(hfPolicy(), handoff.Options{Lease: 9 * time.Hour}); !errors.Is(err, ErrLeaseExceedsClaimWindow) { + t.Fatalf("LeaseOptions with a 9h lease = %v, want ErrLeaseExceedsClaimWindow", err) + } +} + +// NewConsumer must not trust that LeaseOptions was used. A Queue built +// straight from handoff.New is an ordinary thing to have, and it is exactly +// the one that would otherwise skip the only check this file exists to make. +func TestNewConsumerRechecksAQueueItDidNotBuild(t *testing.T) { + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "a.db"))) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + bad, err := handoff.New(db, handoff.Options{Lease: 24 * time.Hour}) + if err != nil { + t.Fatalf("handoff.New: %v", err) + } + if _, err := NewConsumer(bad, hfPolicy()); !errors.Is(err, ErrLeaseExceedsClaimWindow) { + t.Fatalf("NewConsumer over a 24h lease = %v, want ErrLeaseExceedsClaimWindow", err) + } + + good, err := handoff.New(db, handoff.Options{}) + if err != nil { + t.Fatalf("handoff.New: %v", err) + } + if _, err := NewConsumer(good, hfPolicy()); err != nil { + t.Fatalf("NewConsumer over the default lease: %v", err) + } + if _, err := NewConsumer(nil, hfPolicy()); !errors.Is(err, ErrNoQueue) { + t.Fatalf("NewConsumer(nil) = %v, want ErrNoQueue", err) + } +} + +func TestRenewIntervalLeavesRoomForTwoMissedHeartbeats(t *testing.T) { + f := newHFFixture(t, handoff.Options{Lease: 30 * time.Minute}) + got := f.c.RenewInterval() + if got != 10*time.Minute { + t.Fatalf("RenewInterval = %s, want 10m (one third of a 30m lease)", got) + } + // The derivation, asserted as arithmetic rather than as a comment: two + // heartbeats may be missed and a third still lands before expiry. + if 3*got > f.c.Lease() { + t.Fatalf("three intervals (%s) exceed the lease (%s)", 3*got, f.c.Lease()) + } + if got <= 0 { + t.Fatal("RenewInterval must never be zero: that is an infinite heartbeat loop") + } +} + +// --------------------------------------------------------------------------- +// THE CRASH PATH — the packet's first required scenario +// --------------------------------------------------------------------------- + +// sideEffects stands in for whatever a coding agent does to the world: a +// branch, a commit, a comment. It is keyed by handoff idempotency key, which +// is the only key the packet says re-processing must be idempotent under +// (fingerprint, record version — both of which the key contains, transitively, +// via the audit identity and the base commit). +type sideEffects struct { + applied map[string]int + order []string +} + +func newSideEffects() *sideEffects { + return &sideEffects{applied: map[string]int{}} +} + +// apply is the idempotent write every applier is required to be. It records +// the attempt either way, so the test can tell "the second consumer never ran" +// (which would not prove idempotence) from "the second consumer ran and +// recognised the key" (which does). +func (s *sideEffects) apply(key string) { + s.order = append(s.order, key) + if _, seen := s.applied[key]; seen { + return + } + s.applied[key] = 1 +} + +func TestCrashedHolderIsReclaimedAndReprocessedWithoutDoubleApplying(t *testing.T) { + f := newHFFixture(t, handoff.Options{Lease: 20 * time.Minute}) + audit := f.sealedAudit() + fingerprint, row := f.enqueue(1, record.ConsumptionClassStaticOnly, audit, 2) + + effects := newSideEffects() + + // --- attempt 1: the holder takes the lease, does its work, and dies + // before it can report. No ReleaseLease is ever called for it. + dead, err := f.c.Claim(fingerprint, "worker-a") + if err != nil { + t.Fatalf("Claim: %v", err) + } + if dead.Attempt != 1 { + t.Fatalf("first Attempt = %d, want 1", dead.Attempt) + } + effects.apply(dead.IdempotencyKey) + if f.rowState(row.HandoffID) != record.HandoffStateLeased { + t.Fatalf("state after claim = %q, want %q", f.rowState(row.HandoffID), record.HandoffStateLeased) + } + + // --- the lease lapses and the reaper requeues it. One attempt remains. + f.clock.advance(21 * time.Minute) + report, err := f.c.ReclaimExpired() + if err != nil { + t.Fatalf("ReclaimExpired: %v", err) + } + if len(report.Reclaimed) != 1 { + t.Fatalf("Reclaimed = %d rows, want 1", len(report.Reclaimed)) + } + if got := report.Reclaimed[0].To; got != record.HandoffStateReady { + t.Fatalf("reclaimed to %q, want %q — a retry remained", got, record.HandoffStateReady) + } + if report.Requeued() != 1 || report.Exhausted() != 0 { + t.Fatalf("report Requeued/Exhausted = %d/%d, want 1/0", report.Requeued(), report.Exhausted()) + } + + // --- attempt 2: a different worker re-processes through ConsumeOne. + out, err := f.c.ConsumeOne(context.Background(), "worker-b", func(_ context.Context, task Task) (record.HandoffState, error) { + effects.apply(task.IdempotencyKey) + return record.HandoffStateValidated, nil + }) + if err != nil { + t.Fatalf("ConsumeOne: %v", err) + } + if !out.Applied || out.State != record.HandoffStateValidated { + t.Fatalf("Outcome = %+v, want Applied with %q", out, record.HandoffStateValidated) + } + if out.Task.Attempt != 2 { + t.Fatalf("second Attempt = %d, want 2", out.Task.Attempt) + } + + // THE ASSERTION THE PACKET ASKS FOR, in three parts. + + // 1. The successor saw the SAME key. If it did not, re-processing could + // not have been recognised as the same unit of work by anything + // downstream, and the git trailer would name a different unit. + if out.Task.IdempotencyKey != dead.IdempotencyKey { + t.Fatalf("idempotency key changed across reclaim: %q then %q", + dead.IdempotencyKey, out.Task.IdempotencyKey) + } + if out.Task.RecordVersion != dead.RecordVersion { + t.Fatalf("record version changed across reclaim: %d then %d", + dead.RecordVersion, out.Task.RecordVersion) + } + + // 2. Both attempts really did run — otherwise part 3 proves nothing. + if len(effects.order) != 2 { + t.Fatalf("appliers ran %d times, want 2", len(effects.order)) + } + + // 3. The side effect exists exactly once. + if len(effects.applied) != 1 { + t.Fatalf("distinct side effects = %d, want 1: %v", len(effects.applied), effects.applied) + } + if effects.applied[dead.IdempotencyKey] != 1 { + t.Fatalf("side effect applied %d times, want 1", effects.applied[dead.IdempotencyKey]) + } + + // --- and the row itself was not double-counted. + final := f.row(row.HandoffID) + if final.State != record.HandoffStateValidated { + t.Fatalf("final state = %q, want %q", final.State, record.HandoffStateValidated) + } + if final.Attempts != 2 { + t.Fatalf("attempts = %d, want 2 — one per lease granted", final.Attempts) + } +} + +// The other half of "not double-applied": the dead holder waking up. Its Task +// is a snapshot of a lease that no longer exists, and every operation that +// could land work must refuse it. +func TestAStaleTaskCanNeitherRenewReleaseNorRead(t *testing.T) { + f := newHFFixture(t, handoff.Options{Lease: 20 * time.Minute}) + audit := f.sealedAudit() + fingerprint, row := f.enqueue(2, record.ConsumptionClassStaticOnly, audit, 2) + + dead, err := f.c.Claim(fingerprint, "worker-a") + if err != nil { + t.Fatalf("Claim: %v", err) + } + if _, err := f.c.WritePacket(dead, []byte(`{"runs":[]}`)); err != nil { + t.Fatalf("WritePacket: %v", err) + } + + f.clock.advance(21 * time.Minute) + if _, err := f.c.ReclaimExpired(); err != nil { + t.Fatalf("ReclaimExpired: %v", err) + } + live, err := f.c.Claim(fingerprint, "worker-b") + if err != nil { + t.Fatalf("second Claim: %v", err) + } + + // The OOM-killed process comes back and tries to report success. + if err := f.c.ReleaseLease(dead, record.HandoffStateValidated); !errors.Is(err, handoff.ErrLeaseLost) { + t.Fatalf("stale ReleaseLease = %v, want handoff.ErrLeaseLost", err) + } + if _, err := f.c.RenewLease(dead); !errors.Is(err, handoff.ErrLeaseLost) { + t.Fatalf("stale RenewLease = %v, want handoff.ErrLeaseLost", err) + } + if _, err := f.c.Packet(dead); !errors.Is(err, handoff.ErrLeaseLost) { + t.Fatalf("stale Packet = %v, want handoff.ErrLeaseLost", err) + } + if _, err := f.c.WritePacket(dead, []byte(`{}`)); !errors.Is(err, handoff.ErrLeaseLost) { + t.Fatalf("stale WritePacket = %v, want handoff.ErrLeaseLost", err) + } + + // The successor's lease is untouched by any of that. + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("state after the stale writes = %q, want %q", got, record.HandoffStateLeased) + } + if err := f.c.ReleaseLease(live, record.HandoffStateValidated); err != nil { + t.Fatalf("live ReleaseLease: %v", err) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateValidated { + t.Fatalf("final state = %q, want %q", got, record.HandoffStateValidated) + } +} + +// A live holder that heartbeats survives the sweep. Without this, the test +// above would pass on an implementation that simply reclaimed everything. +func TestAHeartbeatingHolderIsNotReclaimed(t *testing.T) { + f := newHFFixture(t, handoff.Options{Lease: 20 * time.Minute}) + audit := f.sealedAudit() + fingerprint, row := f.enqueue(3, record.ConsumptionClassStaticOnly, audit, 2) + + task, err := f.c.Claim(fingerprint, "worker-a") + if err != nil { + t.Fatalf("Claim: %v", err) + } + for i := 0; i < 4; i++ { + f.clock.advance(f.c.RenewInterval()) + if task, err = f.c.RenewLease(task); err != nil { + t.Fatalf("RenewLease %d: %v", i, err) + } + report, err := f.c.ReclaimExpired() + if err != nil { + t.Fatalf("ReclaimExpired: %v", err) + } + if !report.Empty() { + t.Fatalf("sweep %d reclaimed a live lease: %+v", i, report) + } + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("state = %q, want %q after four heartbeats", got, record.HandoffStateLeased) + } + if got := f.row(row.HandoffID).Attempts; got != 1 { + t.Fatalf("attempts = %d, want 1: a heartbeat is not an attempt", got) + } +} + +// --------------------------------------------------------------------------- +// THE CONSUMPTION GATE — the packet's second required scenario +// --------------------------------------------------------------------------- + +func TestRequiresDynamicConfirmationWaitsForTheDastHalf(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + // A SAST-sealed audit whose DAST half is still running. This is the sharp + // case: the static half IS readable, so anything gating on it alone would + // hand the dynamic finding out early. + audit := f.newAudit(record.StateSastSealed, record.HalfStatusSealed, record.DastStatusRunning) + + staticFP, staticRow := f.enqueue(10, record.ConsumptionClassStaticOnly, audit, 2) + dynamicFP, dynamicRow := f.enqueue(11, record.ConsumptionClassRequiresDynamicConfirmation, audit, 2) + + // The static_only finding is claimable now. + if _, err := f.c.Claim(staticFP, "worker-a"); err != nil { + t.Fatalf("static_only Claim on a sast_sealed audit: %v", err) + } + + // The requires_dynamic_confirmation one is not, and says why. + if _, err := f.c.Claim(dynamicFP, "worker-b"); !errors.Is(err, handoff.ErrNotEligible) { + t.Fatalf("requires_dynamic_confirmation Claim = %v, want handoff.ErrNotEligible", err) + } + if got := f.rowState(dynamicRow.HandoffID); got != record.HandoffStateReady { + t.Fatalf("refused row state = %q, want %q — a refusal must not move the row", got, record.HandoffStateReady) + } + + // AcquireLease must not hand it out either. The static row is already + // leased, so the ready set contains only the dynamic one. + if _, err := f.c.AcquireLease("worker-c"); !errors.Is(err, handoff.ErrNoWork) { + t.Fatalf("AcquireLease with only a gated finding ready = %v, want handoff.ErrNoWork", err) + } + + // Now the DAST half reaches a terminal state and the audit seals. + f.setAudit(audit, record.StateBothSealed, record.HalfStatusSealed, record.DastStatusCompletedFindings) + + task, err := f.c.Claim(dynamicFP, "worker-b") + if err != nil { + t.Fatalf("Claim after both halves sealed: %v", err) + } + if task.ConsumptionClass != record.ConsumptionClassRequiresDynamicConfirmation { + t.Fatalf("ConsumptionClass = %q, want %q", task.ConsumptionClass, + record.ConsumptionClassRequiresDynamicConfirmation) + } + if task.DastStatus != record.DastStatusCompletedFindings { + t.Fatalf("DastStatus = %q, want %q", task.DastStatus, record.DastStatusCompletedFindings) + } + _ = staticRow +} + +// The gate, walked across the whole frozen ten-value dastStatus enum rather +// than at one or two sampled values. A future addition to the enum lands in +// this table automatically. +func TestTheGateIsTotalOverTheDastStatusEnum(t *testing.T) { + if got := len(record.DastStatusValues()); got != 10 { + t.Fatalf("anvil/dastStatus has %d values, want the frozen 10; this table must be revisited", got) + } + for i, dast := range record.DastStatusValues() { + t.Run(string(dast), func(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.newAudit(record.StateBothSealed, record.HalfStatusSealed, dast) + fingerprint, _ := f.enqueue(20+i, record.ConsumptionClassRequiresDynamicConfirmation, audit, 2) + + _, err := f.c.Claim(fingerprint, "worker") + // 'running' is the one value that means the half has not + // concluded, and it is refused even from a both_sealed audit — + // the belt-and-braces arm internal/handoff spells out. + if dast == record.DastStatusRunning { + if !errors.Is(err, handoff.ErrNotEligible) { + t.Fatalf("dast_status=%q Claim = %v, want handoff.ErrNotEligible", dast, err) + } + return + } + if err != nil { + t.Fatalf("dast_status=%q Claim = %v, want a grant", dast, err) + } + }) + } +} + +// A finding whose half has not sealed at all is claimable in no lifecycle +// state that is not one of the four the gate names. This is the arm that +// stops a still-collecting audit's default 'not_run' from reading as a +// finished DAST-disabled scan. +func TestNoClassIsClaimableWhileTheAuditIsStillCollecting(t *testing.T) { + for _, class := range record.ConsumptionClassValues() { + t.Run(string(class), func(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.newAudit(record.StateCollecting, record.HalfStatusRunning, record.DastStatusNotRun) + fingerprint, _ := f.enqueue(40, class, audit, 2) + + if _, err := f.c.Claim(fingerprint, "worker"); !errors.Is(err, handoff.ErrNotEligible) { + t.Fatalf("%s Claim on a collecting audit = %v, want handoff.ErrNotEligible", class, err) + } + if _, err := f.c.AcquireLease("worker"); !errors.Is(err, handoff.ErrNoWork) { + t.Fatalf("%s AcquireLease on a collecting audit = %v, want handoff.ErrNoWork", class, err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ConsumeOne +// --------------------------------------------------------------------------- + +// errApplier is the applier's own sentinel. ConsumeOne must wrap it, not +// replace it: a caller that branches on its own error type has to keep working +// through the adapter. +var errApplier = errors.New("test: the coding agent gave up") + +func TestFailedApplierRequeuesThenExhausts(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + _, row := f.enqueue(50, record.ConsumptionClassStaticOnly, audit, 2) + + fail := func(_ context.Context, _ Task) (record.HandoffState, error) { + return "", errApplier + } + + // Attempt 1 of 2: nothing was decided, so the finding goes back to the + // ready set. 'ready' asserts nothing about the finding; a verdict would. + out, err := f.c.ConsumeOne(context.Background(), "worker-a", fail) + if !errors.Is(err, errApplier) { + t.Fatalf("ConsumeOne error = %v, want it to wrap the applier's own error", err) + } + if out.Applied { + t.Error("Outcome.Applied = true after the applier failed") + } + if !errors.Is(out.ApplyErr, errApplier) { + t.Errorf("Outcome.ApplyErr = %v, want errApplier", out.ApplyErr) + } + if out.State != record.HandoffStateReady { + t.Fatalf("fallback state = %q, want %q while an attempt remains", out.State, record.HandoffStateReady) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateReady { + t.Fatalf("row state = %q, want %q", got, record.HandoffStateReady) + } + + // Attempt 2 of 2: the budget is spent, so it lands where the reaper would + // have put it. Same policy, second trigger. + out, err = f.c.ConsumeOne(context.Background(), "worker-b", fail) + if !errors.Is(err, errApplier) { + t.Fatalf("second ConsumeOne error = %v, want it to wrap errApplier", err) + } + if out.State != handoff.ExhaustedState { + t.Fatalf("fallback state = %q, want handoff.ExhaustedState (%q)", out.State, handoff.ExhaustedState) + } + if got := f.rowState(row.HandoffID); got != handoff.ExhaustedState { + t.Fatalf("row state = %q, want %q", got, handoff.ExhaustedState) + } + + // And it is not re-leasable: the attempt budget is spent and terminal is + // terminal. + if _, err := f.c.AcquireLease("worker-c"); !errors.Is(err, handoff.ErrNoWork) { + t.Fatalf("AcquireLease after exhaustion = %v, want handoff.ErrNoWork", err) + } +} + +// The disposition an applier chooses may be refused. When it is, this file +// must not substitute a verdict of its own — the lease stays held and the +// caller decides. +func TestConsumeOneLeavesTheLeaseHeldWhenTheDispositionIsRefused(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + // A DAST half that came back CLEAN: it ran, and it produced no + // reproduction of this finding. plan/00-SPINE.md S7 says that cannot earn + // 'validated' for a requires_dynamic_confirmation finding. + audit := f.newAudit(record.StateBothSealed, record.HalfStatusSealed, record.DastStatusCompletedClean) + _, row := f.enqueue(60, record.ConsumptionClassRequiresDynamicConfirmation, audit, 2) + + out, err := f.c.ConsumeOne(context.Background(), "worker-a", + func(_ context.Context, _ Task) (record.HandoffState, error) { + return record.HandoffStateValidated, nil + }) + if !errors.Is(err, handoff.ErrNoDynamicEvidence) { + t.Fatalf("ConsumeOne = %v, want handoff.ErrNoDynamicEvidence", err) + } + if !out.Applied { + t.Error("Outcome.Applied = false: the applier ran and chose; what failed was recording it") + } + if out.State != "" { + t.Errorf("Outcome.State = %q, want empty: nothing was written", out.State) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("row state = %q, want %q — the lease must survive a refused disposition", + got, record.HandoffStateLeased) + } + // The caller can still land a legal disposition with the Task it holds. + if err := f.c.ReleaseLease(out.Task, record.HandoffStateFailedValidation); err != nil { + t.Fatalf("ReleaseLease after the refusal: %v", err) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateFailedValidation { + t.Fatalf("row state = %q, want %q", got, record.HandoffStateFailedValidation) + } +} + +// An illegal disposition is refused by the frozen state machine, not by a +// second copy of it here. +func TestConsumeOneRefusesADispositionTheStateMachineForbids(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + _, row := f.enqueue(61, record.ConsumptionClassStaticOnly, audit, 2) + + _, err := f.c.ConsumeOne(context.Background(), "worker-a", + func(_ context.Context, _ Task) (record.HandoffState, error) { + return record.HandoffStateLeased, nil + }) + if !errors.Is(err, handoff.ErrIllegalTransition) { + t.Fatalf("ConsumeOne returning 'leased' = %v, want handoff.ErrIllegalTransition", err) + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("row state = %q, want %q", got, record.HandoffStateLeased) + } +} + +func TestConsumeOneWithNoApplierBurnsNoAttempt(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + _, row := f.enqueue(62, record.ConsumptionClassStaticOnly, audit, 2) + + if _, err := f.c.ConsumeOne(context.Background(), "worker-a", nil); !errors.Is(err, ErrNoApplier) { + t.Fatalf("ConsumeOne(nil applier) = %v, want ErrNoApplier", err) + } + got := f.row(row.HandoffID) + if got.Attempts != 0 { + t.Fatalf("attempts = %d, want 0: the refusal must come before the lease", got.Attempts) + } + if got.State != record.HandoffStateReady { + t.Fatalf("state = %q, want %q", got.State, record.HandoffStateReady) + } +} + +func TestConsumeOneIsIdleWhenNothingIsClaimable(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + out, err := f.c.ConsumeOne(context.Background(), "worker-a", + func(_ context.Context, _ Task) (record.HandoffState, error) { + t.Fatal("the applier ran with an empty queue") + return "", nil + }) + if !errors.Is(err, handoff.ErrNoWork) { + t.Fatalf("ConsumeOne on an empty queue = %v, want handoff.ErrNoWork", err) + } + if out.Task.Held() { + t.Error("an idle ConsumeOne returned a Task holding a lease") + } +} + +// --------------------------------------------------------------------------- +// Task carries no authority +// --------------------------------------------------------------------------- + +func TestAHandBuiltTaskGrantsNothing(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + fingerprint, row := f.enqueue(70, record.ConsumptionClassStaticOnly, audit, 2) + + granted, err := f.c.Claim(fingerprint, "worker-a") + if err != nil { + t.Fatalf("Claim: %v", err) + } + + // Every exported field copied; the lease is not, because it cannot be. + forged := Task{ + Fingerprint: granted.Fingerprint, + IdempotencyKey: granted.IdempotencyKey, + RecordVersion: granted.RecordVersion, + ConsumptionClass: granted.ConsumptionClass, + DastStatus: granted.DastStatus, + WorkerID: granted.WorkerID, + Attempt: granted.Attempt, + MaxAttempts: granted.MaxAttempts, + LeaseExpiresAt: granted.LeaseExpiresAt, + PacketPath: granted.PacketPath, + } + if forged.Held() { + t.Fatal("a hand-built Task reports Held()") + } + for name, err := range map[string]error{ + "ReleaseLease": f.c.ReleaseLease(forged, record.HandoffStateValidated), + "RenewLease": second(f.c.RenewLease(forged)), + "Packet": secondBytes(f.c.Packet(forged)), + "WritePacket": secondString(f.c.WritePacket(forged, []byte(`{}`))), + } { + if !errors.Is(err, handoff.ErrLeaseLost) { + t.Errorf("%s on a forged Task = %v, want handoff.ErrLeaseLost", name, err) + } + } + if got := f.rowState(row.HandoffID); got != record.HandoffStateLeased { + t.Fatalf("row state = %q, want %q", got, record.HandoffStateLeased) + } +} + +func second(_ Task, err error) error { return err } +func secondBytes(_ []byte, err error) error { return err } +func secondString(_ string, err error) error { return err } + +func TestTaskAttemptsRemaining(t *testing.T) { + cases := []struct{ attempt, max, want int }{ + {1, 2, 1}, + {2, 2, 0}, + {3, 2, 0}, + {1, 1, 0}, + } + for _, tc := range cases { + got := Task{Attempt: tc.attempt, MaxAttempts: tc.max}.AttemptsRemaining() + if got != tc.want { + t.Errorf("Task{Attempt:%d, MaxAttempts:%d}.AttemptsRemaining() = %d, want %d", + tc.attempt, tc.max, got, tc.want) + } + } +} + +// failureDisposition is the reaper's rule applied to a second trigger. If the +// two ever diverge, this fails. +func TestFailureDispositionMirrorsTheReaper(t *testing.T) { + if got := (Task{Attempt: 1, MaxAttempts: 2}); failureDisposition(got) != record.HandoffStateReady { + t.Errorf("with an attempt remaining = %q, want %q", failureDisposition(got), record.HandoffStateReady) + } + if got := (Task{Attempt: 2, MaxAttempts: 2}); failureDisposition(got) != handoff.ExhaustedState { + t.Errorf("with no attempt remaining = %q, want handoff.ExhaustedState", failureDisposition(got)) + } + if handoff.ExhaustedState == record.HandoffStateReady { + t.Fatal("handoff.ExhaustedState collapsed onto 'ready'; the two branches are no longer distinguishable") + } +} + +// --------------------------------------------------------------------------- +// SOURCE GUARDS — the whole package, not one file of it +// +// §6 G9 was "the handoff table defined and created twice, in two migrations, +// with two Go APIs". The ruling made handoff.go a thin adapter; nothing about a +// ruling stops a later author adding one convenient query. These guards fail the +// build when it happens. +// +// THEY COVER EVERY NON-TEST FILE IN THE PACKAGE, and that widening is CRITIQUE +// O.4 finding O4-m2. They used to parse `handoff.go` alone while being named for +// "the adapter", so statemachine.go and deadlines.go — two thirds of the package +// and the two files the blockers were found in — were unguarded. A guard that +// covers one file of three while appearing to cover the package is worse than no +// guard: it is the false confidence this repository has now paid for three +// times. packageSources is derived by READING THE DIRECTORY rather than from a +// list, so a fourth file added tomorrow is covered the day it lands. +// --------------------------------------------------------------------------- + +// packageSources returns every non-test .go file in this package, parsed. Tests +// run in the package directory, so "." is the package under test. +// +// It fails if it finds fewer than three files: this package has deadlines.go, +// statemachine.go and handoff.go today, and a guard that silently scanned an +// empty set would pass for the wrong reason. +func packageSources(t *testing.T) (*token.FileSet, map[string]*ast.File) { + t.Helper() + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("reading the package directory: %v", err) + } + fset := token.NewFileSet() + files := map[string]*ast.File{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, name, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parsing %s: %v", name, err) + } + files[name] = f + } + if len(files) < 3 { + t.Fatalf("found %d non-test source files (%v); the package has at least three and this guard "+ + "is only worth anything if it scans all of them", len(files), slices.Sorted(maps.Keys(files))) + } + return fset, files +} + +// sourceNames returns the parsed file names in a stable order, so a failure +// message reads the same on every host. +func sourceNames(files map[string]*ast.File) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func TestThePackageOpensNoDatabaseAndWritesNoSQL(t *testing.T) { + fset, files := packageSources(t) + + // 1. Nothing here imports the database or the store. Every row this + // package touches is touched through internal/handoff. + forbidden := map[string]string{ + `"database/sql"`: "no file here may hold a *sql.DB; internal/handoff owns the connection", + `"github.com/Susquehanna-Syntax/Anvil/internal/store"`: "no file here may reach the schema directly; §6 G9", + } + // 2. No string literal anywhere looks like SQL. A second query is a + // second definition of whatever it queries. + keywords := []string{"select ", "insert ", "update ", "delete ", "create table", " from handoff", "handoff h"} + + for _, name := range sourceNames(files) { + file := files[name] + for _, imp := range file.Imports { + if why, bad := forbidden[imp.Path.Value]; bad { + t.Errorf("%s: forbidden import %s: %s", fset.Position(imp.Pos()), imp.Path.Value, why) + } + } + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + lower := strings.ToLower(lit.Value) + for _, kw := range keywords { + if strings.Contains(lower, kw) { + t.Errorf("%s: string literal looks like SQL (%q): internal/handoff owns every query over the handoff table", + fset.Position(lit.Pos()), kw) + } + } + return true + }) + } + + // Negative control: the guard must be able to fail. If these do not trip + // the same predicate, the loop above is checking nothing. + for _, probe := range []string{`"SELECT 1 FROM handoff"`, `"create table handoff (x)"`} { + lower := strings.ToLower(probe) + hit := false + for _, kw := range keywords { + if strings.Contains(lower, kw) { + hit = true + } + } + if !hit { + t.Errorf("negative control %s did not trip the SQL guard", probe) + } + } +} + +// frozenEnumLiterals is every value of every enum internal/record freezes. +// A bare string literal equal to one of them, anywhere in this package's +// adapter, is a second definition of that value — which is how nine of §6's +// ten defects happened. +func strs[T ~string](vs []T) []string { + s := make([]string, len(vs)) + for i, v := range vs { + s[i] = string(v) + } + return s +} + +func frozenEnumLiterals() map[string]string { + out := map[string]string{} + add := func(enum string, values ...string) { + for _, v := range values { + out[v] = enum + } + } + add("anvil/state", strs(record.StateValues())...) + add("anvil/status", strs(record.HalfStatusValues())...) + add("anvil/dastStatus", strs(record.DastStatusValues())...) + add("anvil/target.provenance", strs(record.TargetProvenanceValues())...) + add("anvil/target.provisioning", strs(record.TargetProvisioningValues())...) + add("anvil/verdict", strs(record.VerdictValues())...) + add("handoff.state", strs(record.HandoffStateValues())...) + add("handoff.consumption_class", strs(record.ConsumptionClassValues())...) + return out +} + +func TestThePackageUsesNoBareEnumLiteral(t *testing.T) { + frozen := frozenEnumLiterals() + if len(frozen) < 40 { + t.Fatalf("collected %d frozen enum literals, want the full set; the guard is not covering them", len(frozen)) + } + if _, ok := frozen["both_sealed"]; !ok { + t.Fatal("negative control: 'both_sealed' is not in the frozen set, so the guard is looking at the wrong thing") + } + + fset, files := packageSources(t) + for _, name := range sourceNames(files) { + ast.Inspect(files[name], func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + v, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + if enum, bad := frozen[v]; bad { + t.Errorf("%s: bare literal %q is a %s value; use the record constant", + fset.Position(lit.Pos()), v, enum) + } + return true + }) + } +} + +// --------------------------------------------------------------------------- +// The read-gate guard — CRITIQUE O.4 finding O4-m2, second half +// +// internal/record has TestReadGateArmsAppearOnlyInsideTheGate, which parses "." +// and therefore watches internal/record and nothing else. The critic's point +// was that nothing watched THIS package, and O4-B1 is what walked through that +// gap: a readability decision assembled here out of the two arms of a gate that +// lives there. +// +// So this is that guard, for this package. The rule it enforces: +// +// record.HalfStatusSealed and record.IsReadableHalfStatus may not be named +// here AT ALL. "Which status is readable" is internal/record's question and +// has exactly one answer, inside internal/record. +// +// record.StateExpired and record.StateConsumed may be named ONLY inside the +// two functions that are documented as non-readability predicates, and each +// of those already carries the argument for why it is not a read gate. +// +// The allowlist is by ENCLOSING FUNCTION, not by file, so a new helper in +// statemachine.go cannot inherit settled's licence by living next to it. +// --------------------------------------------------------------------------- + +// readGateArms are the record identifiers that decide readability. Naming one +// outside internal/record is re-deriving the gate. +var readGateArms = map[string]string{ + "HalfStatusSealed": "the readable status; ask record.Sealer.ReadHalf or record.HalfSeal.Readable instead", + "IsReadableHalfStatus": "record's own readability predicate; it is not exported for re-use in a second gate", + "HalfReadGate": "the gate itself; reach it through record.Sealer.ReadHalf, which mints the seal it gates", + "ErrSealNotFromProducer": "a provenance refusal is record's to raise, not this package's to reproduce", +} + +// stateArmAllowlist names the functions permitted to compare an anvil/state +// against a terminal value, with the reason each is not a readability decision. +var stateArmAllowlist = map[string]string{ + "settled": "a DURABILITY predicate: has the store writer's one chance arrived", + "acceptsWrites": "a WRITE guard: mirrors record.Sealer's own ErrAuditTerminal check", +} + +var terminalStateArms = map[string]bool{ + "StateExpired": true, "StateConsumed": true, +} + +// recordSelector reports the identifier X in an expression `record.X`. +func recordSelector(n ast.Node) (string, bool) { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return "", false + } + pkg, ok := sel.X.(*ast.Ident) + if !ok || pkg.Name != "record" { + return "", false + } + return sel.Sel.Name, true +} + +// scanReadGateArms walks one file and reports every violation as a string. +// It is shared by the guard and by its negative control, so the control +// exercises the same code the guard runs. +func scanReadGateArms(fset *token.FileSet, file *ast.File) []string { + var found []string + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + ast.Inspect(fn, func(n ast.Node) bool { + name, ok := recordSelector(n) + if !ok { + return true + } + if why, banned := readGateArms[name]; banned { + found = append(found, fmt.Sprintf("%s: %s names record.%s: %s", + fset.Position(n.Pos()), fn.Name.Name, name, why)) + return true + } + if terminalStateArms[name] { + if _, allowed := stateArmAllowlist[fn.Name.Name]; !allowed { + found = append(found, fmt.Sprintf( + "%s: %s compares an anvil/state against record.%s; that is an arm of the read gate. "+ + "If this is a durability or write decision, say so in its doc and add it to stateArmAllowlist; "+ + "if it is a readability decision, it belongs to record.Sealer.ReadHalf", + fset.Position(n.Pos()), fn.Name.Name, name)) + } + } + return true + }) + } + return found +} + +func TestReadGateArmsAreNotReDerivedInThisPackage(t *testing.T) { + fset, files := packageSources(t) + for _, name := range sourceNames(files) { + for _, v := range scanReadGateArms(fset, files[name]) { + t.Error(v) + } + } + + // The allowlist must not outlive the functions it names. An entry for a + // function that no longer exists is a licence nobody asked for, sitting + // where the next author will read it as precedent. + live := map[string]bool{} + for _, name := range sourceNames(files) { + for _, decl := range files[name].Decls { + if fn, ok := decl.(*ast.FuncDecl); ok { + live[fn.Name.Name] = true + } + } + } + for name := range stateArmAllowlist { + if !live[name] { + t.Errorf("stateArmAllowlist names %q, which no longer exists in this package", name) + } + } + + // NEGATIVE CONTROL. The guard must be able to fail, and it must fail on + // both arms: the banned identifier and the un-allowlisted state comparison. + // Without this the loop above is a test that asserts nothing. + probe := `package scanctl + +import "github.com/Susquehanna-Syntax/Anvil/internal/record" + +func sneakyReadable(s record.State, h record.HalfStatus) bool { + return s != record.StateExpired && h == record.HalfStatusSealed +} +` + probeSet := token.NewFileSet() + probeFile, err := parser.ParseFile(probeSet, "probe.go", probe, parser.ParseComments) + if err != nil { + t.Fatalf("parsing the negative control: %v", err) + } + hits := scanReadGateArms(probeSet, probeFile) + if len(hits) != 2 { + t.Errorf("negative control produced %d violations, want 2 (one per arm): %v", len(hits), hits) + } + + // And the allowlist must really allow: the same body inside `settled` must + // trip only the HalfStatusSealed arm, not the state arm. + allowed := strings.Replace(probe, "sneakyReadable", "settled", 1) + allowedFile, err := parser.ParseFile(probeSet, "allowed.go", allowed, parser.ParseComments) + if err != nil { + t.Fatalf("parsing the allowlist control: %v", err) + } + if hits := scanReadGateArms(probeSet, allowedFile); len(hits) != 1 { + t.Errorf("allowlist control produced %d violations, want 1 (the state arm must be allowed in settled): %v", + len(hits), hits) + } +} + +// The adapter must not be the only thing standing between a caller and the +// queue: Queue() is the documented escape hatch, and the packet path it +// returns is the same one the Task carries. If those two ever disagree, a +// caller reaching around the adapter reads a different file from one going +// through it. +func TestTheEscapeHatchAgreesWithTheAdapter(t *testing.T) { + f := newHFFixture(t, handoff.Options{}) + audit := f.sealedAudit() + fingerprint, _ := f.enqueue(80, record.ConsumptionClassStaticOnly, audit, 2) + + task, err := f.c.Claim(fingerprint, "worker-a") + if err != nil { + t.Fatalf("Claim: %v", err) + } + direct, err := f.c.Queue().PacketPath(fingerprint) + if err != nil { + t.Fatalf("PacketPath: %v", err) + } + if task.PacketPath != direct { + t.Fatalf("Task.PacketPath = %q, Queue().PacketPath = %q", task.PacketPath, direct) + } + + body := []byte(`{"version":"2.1.0","runs":[]}`) + if _, err := f.c.WritePacket(task, body); err != nil { + t.Fatalf("WritePacket: %v", err) + } + got, err := f.c.Packet(task) + if err != nil { + t.Fatalf("Packet: %v", err) + } + if string(got) != string(body) { + t.Fatalf("Packet round trip = %q, want %q", got, body) + } + + // Terminal disposition drops the cache; its absence is not an error. + if err := f.c.ReleaseLease(task, record.HandoffStateValidated); err != nil { + t.Fatalf("ReleaseLease: %v", err) + } + if _, err := os.Stat(direct); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stat packet after a terminal disposition = %v, want os.ErrNotExist", err) + } +} diff --git a/internal/scanctl/statemachine.go b/internal/scanctl/statemachine.go new file mode 100644 index 0000000..4a57391 --- /dev/null +++ b/internal/scanctl/statemachine.go @@ -0,0 +1,1395 @@ +// The scan controller's state machine (step O.2): the wiring that turns +// worker events into plan/00-SPINE.md S10's "one state machine with one +// owner", and the version-bump watermarks that make research/21 +// Recommendation §5's incremental publication happen without a per-finding +// write storm. +// +// # THIS FILE IMPLEMENTS NO STATE MACHINE OF ITS OWN +// +// plan/IMPLEMENTATION-PLAN.md §6 ruling G2 struck O.2's original +// `open | sast_sealed | sealed | expired` machine outright, on two grounds +// that are worth restating because both are easy to re-derive by accident: +// +// - It could not express a DAST-FIRST seal at all. plan/00-SPINE.md S1 +// requires "two INDEPENDENTLY-sealed halves", and the SAST half can be +// slow, or can fail, while the DAST half finishes first. +// - It made `sealed` terminal, which makes `consumed` unreachable, which +// silently disables the re-entrant consumer the same spine item requires. +// +// The replacement is not a re-drawn machine in this file. It is +// record.Sealer, which already owns: +// +// record.State / record.StateValues the six-value anvil/state enum +// record.DeriveState halves -> anvil/state +// record.DeriveDastStatus (half status, provenance) -> anvil/dastStatus +// record.Sealer.BeginAudit fixes deadline_at, once +// record.Sealer.SealHalf the per-half terminal transition +// record.Sealer.RecordDastOutcome the target-lifecycle facts +// record.Sealer.Consume both_sealed -> consumed +// record.Sealer.ExpireIfDue -> expired, and only when due (clock 2) +// record.Sealer.SealDastIfDeadlineDue -> dast timed_out, when due (clock 3) +// record.Sealer.ReadyForConsumption the per-half consumption gate +// record.Sealer.ReadHalf THE read gate, over a seal it mints +// +// Every one of those is called from here and none of them is re-derived here. +// A second DeriveState in this package would be ruling G2 being broken a +// second time; a locally re-derived read gate would be the defect +// internal/record/sealing.go's header records FIVE authors making. +// +// WHAT THIS FILE ADDS, and it is only these four things: +// +// 1. AuditRecord — research/21 §5's `audit_record` shape as a Go value, with +// the four fields the Sealer does not carry: the monotonic `version`, the +// per-half `findings[]`, the `correlation` clusters, and the two deadline +// instants from deadlines.go (O.1). It is a SNAPSHOT; see below. +// 2. Event — the vocabulary of things that happen TO an audit. It is +// deliberately NOT a state vocabulary; the states are record's. +// 3. Transition — apply one event, then re-project from the Sealer, so the +// lifecycle fields on the returned record are always the Sealer's answer +// and never this file's opinion. +// 4. WatermarkPolicy — research/21 §5's "version-bump on watermarks, not per +// finding", as config rather than constants. +// +// # THE CONTROLLER OWNS THE MUTABLE STATE; AN AuditRecord IS A SNAPSHOT OF IT +// +// This is the shape CRITIQUE O.4 forced, and three of its findings are one +// mistake seen from three sides. The buffers, the version counter and the +// watermark bookkeeping used to live on the AuditRecord value the CALLER held, +// with the Sealer holding only the lifecycle. That had two consequences: +// +// - Fan-in lost RESULTS. Eight workers each cloning their own copy of one +// record and each returning a new one meant the last writer won: the critic +// measured 21 of 24 DAST findings silently dropped, on a security scanner +// (O4-M3). The doc called that "a skipped version bump, not a corrupt +// lifecycle" — true of the lifecycle, and wrong about the findings, which +// is the half an implementer would have acted on. +// - Every guard read a value the caller owned. The read gate and the two +// write guards were asked about `rec.State`, which stops tracking the +// Sealer the moment the caller stops calling Transition, so an EXPIRED +// audit was both readable and writable through a record taken before it +// expired (O4-B1, O4-M2). +// +// So Controller holds one `auditState` per audit under one mutex, and an +// AuditRecord is a value PROJECTED from (that state, the Sealer's AuditSeal) at +// the instant it was produced. Transition reads the audit id off the record it +// is handed and NOTHING ELSE: two goroutines passing in the same stale snapshot +// both append, and neither can overwrite the other. Controller.Record is the +// refresh path whose absence O4-B1 turned into a readable expired audit. +// +// A snapshot is still a snapshot: it can be stale, and it carries no gate. That +// is why Findings and Readable are METHODS ON THE CONTROLLER, which re-Inspect +// and go through record.Sealer.ReadHalf, rather than methods on AuditRecord, +// which could only ever answer about the past. +// +// # PER-HALF TRANSITIONS KEY ON `sealed`, NEVER ON `complete` +// +// Ruling G5. `complete` is struck from the vocabulary; record.HalfStatusSealed +// is the token, and R.6 makes it the hard consumer read gate. A controller +// keying its transition on any other token is a controller whose read gate +// never opens. There is no `complete` anywhere in this package, and no bare +// string literal for any enum value — a second copy of a literal is a second +// definition, which is how nine of §6's ten defects happened. +// +// # WHERE `created_at` WENT +// +// research/21 §5 writes the record shape with a `created_at` field and glosses +// `deadline_at` as `created_at + 8h`, "anchored to scan START, never to last +// write". deadlines.go resolved that spelling against the frozen schema: the +// anchor is `scan_run.started_at`, and `audit_record.created_at` is a separate +// WRITE timestamp owned by the store writer. So AuditRecord carries no +// `CreatedAt`; the scan-start instant research/21 meant is +// AuditRecord.Deadlines.StartedAt(), in one place, spelled the schema's way. +// Carrying a second copy under research/21's name is exactly the +// "two areas meaning different things by the same field name" class §6 was +// convened over. +// +// (Free-floating file comment: deadlines.go carries the package doc.) + +package scanctl + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +// The sentinels this file adds. There are only three, and each names a +// refusal internal/record has no opinion about. Every OTHER refusal a +// transition can produce is record's own — record.ErrUnknownAudit, +// record.ErrNotSealable, record.ErrHalfAlreadySealed, record.ErrAuditTerminal, +// record.ErrNotBothSealed, record.ErrHalfNotSealed, *record.EnumError — and is +// returned UNWRAPPED, so a caller branching with errors.Is sees the frozen +// package's answer rather than a re-spelling of it. Wrapping them in a scanctl +// error type would be a second refusal vocabulary over a frozen one. +var ( + // ErrUnknownEvent: the Event's Kind is not one of EventKindValues. + // The zero Event lands here, which is deliberate — an accidentally + // zero-valued event must not silently mean "tick". + ErrUnknownEvent = errors.New("scanctl: unknown event kind") + + // ErrEmptyEvent: the event carries no payload and would therefore be a + // silent no-op. O.2's validation requirement is that an illegal + // transition "returns an error, not a panic or a silent no-op"; an + // event that changes nothing is the no-op case. + ErrEmptyEvent = errors.New("scanctl: event carries no payload") + + // ErrHalfNotAccepting: findings arrived for a half that has already + // reached one of record.TerminalHalfStatuses. A sealed half's results + // are frozen; appending to them after the fact would move what a + // consumer already read. + ErrHalfNotAccepting = errors.New("scanctl: half is terminal and accepts no further findings") + + // ErrInvalidWatermarkPolicy: a negative N or M. Zero means "use the + // derived default" and is not an error. + ErrInvalidWatermarkPolicy = errors.New("scanctl: invalid watermark policy") +) + +// TransitionError reports a refused transition, naming the event, the audit, +// and the lifecycle state at the moment of refusal — the same shape as +// record.SealingError and scanctl.PolicyError, for the same reason: the +// message should identify the offending caller, not merely the offence. +type TransitionError struct { + Kind EventKind // the event that was refused + AuditID string // anvil/auditId + Half record.Half // empty when the event is not half-scoped + State record.State // anvil/state at the moment of refusal + Reason string + Err error // one of the sentinels above, or a *record.EnumError +} + +func (e *TransitionError) Error() string { + msg := fmt.Sprintf("scanctl: event %s on audit %q", e.Kind, e.AuditID) + if e.Half != "" { + msg += fmt.Sprintf(" half=%s", e.Half) + } + if e.State != "" { + msg += fmt.Sprintf(" state=%s", e.State) + } + return msg + ": " + e.Reason +} + +// Unwrap exposes the sentinel to errors.Is. +func (e *TransitionError) Unwrap() error { return e.Err } + +// --------------------------------------------------------------------------- +// WatermarkPolicy — research/21 §5's "bump on watermarks, not per finding" +// --------------------------------------------------------------------------- + +// WatermarkPolicy is the configuration behind research/21 §5's version-bump +// rule, verbatim: "Version-bump on watermarks, not per finding: bump on (a) +// `sast.status -> complete`, (b) every N DAST findings or every M minutes, +// whichever first, (c) `dast` terminal state. Per-finding publication turns +// the buffer into a write-amplification hotspot on the same disk the DB is +// on." +// +// (a) and (c) need no configuration — they are events, and this file bumps on +// ANY terminal seal of EITHER half. The generalisation is deliberate: +// plan/00-SPINE.md S6 requires the work queue to be re-cut on every version +// bump, and a SAST half that reached record.HalfStatusFailed has changed the +// record just as materially as one that reached record.HalfStatusSealed — the +// queue must learn that no more SAST findings are coming. (research/21 wrote +// (a) as `complete`, which ruling G5 struck; record.HalfStatusSealed is the +// token, and "terminal" is the classification record.IsTerminalHalfStatus +// owns.) +// +// N and M are (b), and they are DATA, never constants: plan/00-SPINE.md S1 +// makes "no hard-coded triggers" a hard constraint and research/21 §5 extends +// it to the companion controls explicitly. The zero WatermarkPolicy is +// meaningful and resolves to the derived defaults below; it is not an error. +type WatermarkPolicy struct { + // DastFindings is N: publish once this many DAST findings have arrived + // since the last publication. Zero means DefaultWatermarkDastFindings. + // Negative is rejected. One would be per-finding publication, which is + // the thing research/21 §5 names as the failure; it is permitted rather + // than rejected, because refusing a value the research merely advises + // against would make this package a second, stricter policy authority + // (see Deadlines.DastDeadlineBinds for the same argument made once + // already). + DastFindings int + + // Interval is M: publish once this long has elapsed since the last + // publication AND at least one DAST finding is unpublished. Zero means + // the value DefaultWatermarkInterval derives from the audit's DAST + // budget. Negative is rejected. + // + // It fires only on EventKindTick. Nothing in this package runs a timer; + // see Controller.NextWake for the instant a daemon must wake at. + Interval time.Duration +} + +// DefaultWatermarkDastFindings is N's default: 50. +// +// DERIVED, not chosen. research/21 §4 bounds each per-scan finding ring at +// "5,000 findings / 64 MiB per side per scan (suggested defaults, +// configurable)". Publishing every 1% of that bound means a ring that fills +// completely produces at most 100 publications instead of 5,000 — two orders +// of magnitude off the per-finding write amplification §5 forbids, while still +// giving a consumer roughly a hundred progressively better cuts of the queue +// over the worst-case scan. Both numbers move together if an operator +// re-sizes the ring, which is why the relation is written down here and the +// value is config. +const DefaultWatermarkDastFindings = 50 + +// WatermarkIntervalDivisor is the number of time-triggered publications the +// default M allows across one audit's whole DAST budget: 16, i.e. staleness +// bounded at 6.25% of the budget. +// +// It is a RELATION rather than a duration for the same reason +// DefaultDastDeadlineSeconds is: writing "15 minutes" down would silently +// decouple publication from the budget the moment an operator shortened it, +// and an M longer than the DAST deadline is a watermark that can never fire. +const WatermarkIntervalDivisor = 16 + +// DefaultWatermarkInterval returns M derived from an audit's DAST budget: +// budget / WatermarkIntervalDivisor, clamped to a minimum of one second so a +// test fixture's tiny budget cannot produce a zero or negative interval. +// +// At research/21 §5's shipped default budget of 4h it returns 15 minutes. +// +// WHAT "budget" IS. The resolved DAST deadline when the installation has a +// DAST half, and half the claim window otherwise — which is the same quantity +// DefaultDastDeadlineSeconds computes, so the derived M does not jump when an +// operator installs `anvil-dast` and accepts the default deadline. +// Controller.Resolve does that selection once; callers should not repeat it. +func DefaultWatermarkInterval(budget time.Duration) time.Duration { + if d := budget / WatermarkIntervalDivisor; d > 0 { + return d + } + return time.Second +} + +// Resolve fills in the derived defaults and validates. budget is the audit's +// DAST budget, as described on DefaultWatermarkInterval. +// +// Resolve is idempotent: resolving an already-resolved policy against the same +// budget returns it unchanged. +func (w WatermarkPolicy) Resolve(budget time.Duration) (WatermarkPolicy, error) { + out := WatermarkPolicy{} + + switch { + case w.DastFindings < 0: + return WatermarkPolicy{}, &PolicyError{ + Field: "watermark.dastFindings", Value: fmt.Sprint(w.DastFindings), + Reason: "N is a count of findings and cannot be negative", + Err: ErrInvalidWatermarkPolicy, + } + case w.DastFindings == 0: + out.DastFindings = DefaultWatermarkDastFindings + default: + out.DastFindings = w.DastFindings + } + + switch { + case w.Interval < 0: + return WatermarkPolicy{}, &PolicyError{ + Field: "watermark.interval", Value: w.Interval.String(), + Reason: "M is an elapsed duration and cannot be negative", + Err: ErrInvalidWatermarkPolicy, + } + case w.Interval == 0: + out.Interval = DefaultWatermarkInterval(budget) + default: + out.Interval = w.Interval + } + + return out, nil +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +// EventKind names a thing that HAPPENS TO an audit. +// +// IT IS NOT A STATE VOCABULARY, and none of its literals is a record enum +// token. That separation is load-bearing: plan/IMPLEMENTATION-PLAN.md §6's +// through-line ruling is that area 40 owns every shared enum "because it owns +// the record contract, and no other area may declare one". An event kind is +// not a record field, is never serialised onto a record, and is never written +// to a column — it is this package's internal dispatch tag, and it is spelled +// so that it cannot be mistaken for one of R.1's six frozen enums. +type EventKind string + +// The six event kinds. Each maps onto exactly one record.Sealer entry point, +// except EventKindTick (two clocks) and EventKindFindings (none — findings +// buffer in this package until a watermark publishes them). +const ( + // EventKindFindings: a worker produced findings for one half. Buffered; + // publishes only on watermark (b). Refused once the half is terminal. + EventKindFindings EventKind = "findings" + + // EventKindDastOutcome: the target lifecycle harness (area D) reported + // provenance and coverage facts. Forwarded to + // record.Sealer.RecordDastOutcome, which is where anvil/dastStatus is + // derived from. Never a publication on its own. + EventKindDastOutcome EventKind = "dast_outcome" + + // EventKindSealHalf: a half reached a terminal status. Forwarded to + // record.Sealer.SealHalf. Publishes — watermarks (a) and (c). + EventKindSealHalf EventKind = "seal_half" + + // EventKindTick: the daemon woke. Drives clock 3 (the DAST deadline, + // due-check record.Sealer.SealDastIfDeadlineDue) and then clock 2 (the + // claim timeout, due-check record.Sealer.ExpireIfDue), plus watermark + // (b)'s time arm. Neither due-check is this package's; see applyTick. + // Idempotent: ticking an expired audit is not an error. + EventKindTick EventKind = "tick" + + // EventKindCorrelate: R.12's correlator produced clusters. Stored on the + // record; NOT a publication watermark, because research/21 §5 lists + // three and this is not one of them — the clusters land with the DAST + // terminal bump that follows. + EventKindCorrelate EventKind = "correlate" + + // EventKindConsume: the coding-agent consumption pipeline took the + // record. Forwarded to record.Sealer.Consume, which requires + // record.StateBothSealed. Not a publication: consumption does not + // re-publish, and the consumer is re-entrant. + EventKindConsume EventKind = "consume" +) + +// EventKindValues returns every legal EventKind, in the order the doc above +// lists them. +func EventKindValues() []EventKind { + return []EventKind{ + EventKindFindings, EventKindDastOutcome, EventKindSealHalf, + EventKindTick, EventKindCorrelate, EventKindConsume, + } +} + +// Valid reports whether k is one of EventKindValues. +func (k EventKind) Valid() bool { + for _, v := range EventKindValues() { + if k == v { + return true + } + } + return false +} + +// Event is one thing that happened to an audit. Build one with the +// constructors below rather than by hand: the zero Event has an invalid Kind +// and Transition refuses it with ErrUnknownEvent, which is the intended +// treatment of an accidentally-zero event but a poor way to write one on +// purpose. +type Event struct { + Kind EventKind + + // Half is set by EventKindFindings and EventKindSealHalf. + Half record.Half + + // Status is set by EventKindSealHalf. It must be one of + // record.TerminalHalfStatuses; record.Sealer.SealHalf enforces that and + // returns record.ErrNotSealable otherwise. record.HalfStatusRunning is + // the value this catches — "started" is not "sealed". + Status record.HalfStatus + + // Findings is set by EventKindFindings. + Findings []record.Result + + // Outcome is set by EventKindDastOutcome. Its TierInstalled field is + // overwritten by the Sealer with the audit's own AuditConfig.DastEnabled, + // so a caller cannot report an outcome for a tier the audit was not + // started with. + Outcome record.DastOutcome + + // Correlation is set by EventKindCorrelate. + Correlation []record.Correlation +} + +// FindingsEvent reports findings arriving on one half. +func FindingsEvent(half record.Half, findings ...record.Result) Event { + return Event{Kind: EventKindFindings, Half: half, Findings: findings} +} + +// SealHalfEvent reports one half reaching a terminal status. status must be +// one of record.TerminalHalfStatuses. +func SealHalfEvent(half record.Half, status record.HalfStatus) Event { + return Event{Kind: EventKindSealHalf, Half: half, Status: status} +} + +// DastOutcomeEvent reports the target lifecycle and coverage facts +// record.DeriveDastStatus consumes. +func DastOutcomeEvent(o record.DastOutcome) Event { + return Event{Kind: EventKindDastOutcome, Half: record.HalfDast, Outcome: o} +} + +// TickEvent reports that the daemon woke and the clocks should be evaluated. +func TickEvent() Event { return Event{Kind: EventKindTick} } + +// CorrelateEvent reports correlation clusters from R.12's correlator. +// +// EACH BATCH IS THE COMPLETE SET. It REPLACES whatever clusters the audit was +// carrying; it does not accumulate. A correlator that emits partial batches +// would lose the earlier ones, so it must emit its whole current answer each +// time. See applyCorrelation for why replacement rather than accumulation is +// the contract this package can actually honour. +func CorrelateEvent(clusters ...record.Correlation) Event { + return Event{Kind: EventKindCorrelate, Correlation: clusters} +} + +// ConsumeEvent reports the consumption pipeline taking the record. +func ConsumeEvent() Event { return Event{Kind: EventKindConsume} } + +// --------------------------------------------------------------------------- +// AuditRecord +// --------------------------------------------------------------------------- + +// HalfRecord is one half of research/21 §5's record shape: `{ status, +// sealed_at, findings[] }`. +// +// Status and SealedAt are PROJECTIONS of record.Sealer's answer, re-read from +// the Sealer after every transition. Assigning to them on a copy changes +// nothing durable and is overwritten by the next Transition; the Sealer is the +// authority and this struct is a view of it. +type HalfRecord struct { + // Half is record.HalfSast or record.HalfDast. + Half record.Half + + // Status is the per-half `anvil/status` — R.1's frozen five-value enum, + // in which record.HalfStatusSealed and nothing else is the read gate. + Status record.HalfStatus + + // SealedAt is `anvil/sealedAt`, non-nil only when Status is + // record.HalfStatusSealed. It is NOT the claim clock; see + // AuditRecord.Deadlines. + SealedAt *time.Time + + // findings is `findings[]`. UNEXPORTED on purpose: reading a half's + // results is gated, and an exported slice field is an ungated read. + // Controller.Findings is the gated accessor. + findings []record.Result +} + +// FindingCount reports how many findings this half has buffered. +// +// IT IS NOT GATED, and that is deliberate rather than an oversight. A count is +// audit-level metadata, not results: record.DastOutcome.FindingCount is the +// input record.DeriveDastStatus uses to tell record.DastStatusCompletedClean +// from record.DastStatusCompletedFindings, and that derivation runs BEFORE the +// half seals, so a gated count could not exist. What the gate protects is the +// results themselves — see AuditRecord.Findings. +func (h HalfRecord) FindingCount() int { return len(h.findings) } + +// AuditRecord is one audit as the scan controller holds it: research/21 §5's +// `audit_record` shape, re-cut by plan/IMPLEMENTATION-PLAN.md §6's rulings. +// +// It is a VALUE. Transition takes one and returns a new one; the input is +// never mutated, so a caller may keep the previous version to diff against +// (which is what VersionBumped and DurableWriteDue do). +// +// The lifecycle fields — State, the two Status/SealedAt pairs, DastStatus — +// are re-projected from record.Sealer after every transition. Nothing a caller +// writes into them survives, and nothing in this package computes them. +type AuditRecord struct { + // AuditID is research/21 §5's `scan_id`, spelled the record contract's + // way: `anvil/auditId`, `scan_run.audit_id`. Assigned once at scan start. + AuditID string + + // Version is `anvil/version` / `audit_record.audit_version`, the + // monotonic integer research/21 §5 requires. It starts at 1 (the + // schema's ck_audit_record_audit_version_positive requires >= 1) and is + // bumped by the three watermarks WatermarkPolicy documents. Every bump + // obliges plan/00-SPINE.md S6's queue re-cut (R.11), "otherwise + // incremental publication silently inverts the priority scheme". + Version int + + // State is `anvil/state`: R.1's frozen six-value enum, as derived by + // record.DeriveState and advanced by record.Sealer. Never assigned here. + State record.State + + // Sast and Dast are the two independently-sealed halves. + Sast HalfRecord + Dast HalfRecord + + // DastStatus is `anvil/dastStatus`, derived by record.DeriveDastStatus + // from the DAST half's status and the target's provenance. Never null: + // `audit_record.dast_status` is NOT NULL and the enum has no value + // meaning "unknown". + DastStatus record.DastStatus + + // Deadlines carries both scan-scoped clocks, fixed once at scan start by + // DeadlinePolicy.At. Deadlines.StartedAt() is `scan_run.started_at` — the + // instant research/21 §5 called `created_at`; see this file's header for + // why there is no second copy under that name. + // + // It is a DIAGNOSTIC COPY. Deadlines has no exported field so nothing can + // move an instant on it, and nothing reads this copy to decide anything: + // scheduling reads the Controller's own copy and both due-checks are the + // Sealer's. Assigning a whole different Deadlines here therefore changes + // what a caller sees and nothing else, which is what CRITIQUE O.4 blocker 2 + // asked for. + Deadlines Deadlines + + // Correlation is research/21 §5's `correlation { ... }`, "populated as + // both sides land". Produced by R.12's correlator and delivered by + // EventKindCorrelate; nothing here computes a cluster. + // + // Each EventKindCorrelate REPLACES this whole slice — see CorrelateEvent. + Correlation []record.Correlation + + // ConsumedAt is `audit_record.consumed_at`, stamped when + // record.Sealer.Consume accepts. Nil until then. Re-consuming does not + // move it: the consumer is re-entrant and the first take is the one the + // audit trail records. + ConsumedAt *time.Time + + // PublishedAt is the instant of the most recent version bump, and the + // anchor watermark (b)'s M is measured from. Set to Deadlines.StartedAt() + // at Begin, because version 1 is itself a publication. + PublishedAt time.Time + + // PendingDastFindings is how many DAST findings have arrived since the + // last publication — watermark (b)'s N counter. Reset to zero by every + // bump, whichever watermark caused it. + PendingDastFindings int +} + +// THERE IS NO AuditRecord.HalfSeal, AuditRecord.Readable OR AuditRecord.Findings. +// +// There were, and CRITIQUE O.4 blocker 1 is what they cost. `HalfSeal` built the +// record.HalfSeal the gate takes out of TWO FIELDS OF THE CALLER'S OWN VALUE — +// `Status` from the record's half and `AuditState` from the record's `State` — +// and `Findings` then handed that to record.HalfReadGate. The gate was called +// and neither arm was reimplemented, which is what made the mistake so hard to +// see: what was wrong was not the predicate but the SUBJECT. The value stopped +// tracking the Sealer the moment the caller stopped calling Transition, and +// there was no refresh path at all. The critic's probe P3 read one finding out +// of an EXPIRED audit through a record taken before the expiry, with +// `Readable()` answering true, while record.Sealer.ReadHalf on the same audit +// refused. +// +// Two things changed, and BOTH are necessary: +// +// 1. internal/record made a hand-built seal unusable. record.HalfSeal now +// carries unexported provenance, so a seal no producer minted is refused +// with record.ErrSealNotFromProducer, and a genuinely-minted seal held +// across a state change is refused as stale. A composite literal in this +// package cannot set an unexported field — that is a compile error, not a +// lint — so the old HalfSeal method could not have survived even if it had +// been kept. +// 2. This package stopped asking the question from a snapshot. The result +// surface is Controller.Findings / Controller.Readable, which re-Inspect +// the audit and route through record.Sealer.ReadHalf: the seal is minted by +// the producer, checked against the live audit, and gated, all inside +// internal/record. There is nothing left here for a caller's stale value to +// be substituted into. +// +// What remains on AuditRecord is HalfRecord.FindingCount, which is metadata and +// is documented as ungated for a reason that has not changed. + +// copyTime defensively copies an optional instant, so two AuditRecords never +// share a *time.Time. record.copyTime does the same for the same reason; this +// package cannot call it, because it is unexported there. +func copyTime(t *time.Time) *time.Time { + if t == nil { + return nil + } + c := *t + return &c +} + +// --------------------------------------------------------------------------- +// Publication diffs — what a caller does BETWEEN two records +// --------------------------------------------------------------------------- + +// VersionBumped reports whether the transition from before to after published +// a new version, and therefore whether plan/00-SPINE.md S6's queue re-cut is +// owed: "re-cut the work queue on every version bump and reserve a +// configurable fraction (default 50%) of remaining budget for late +// DAST-confirmed arrivals". The reservation fraction is R.11's; the trigger is +// this. +func VersionBumped(before, after AuditRecord) bool { return after.Version > before.Version } + +// DurableWriteDue reports whether this transition is the ONE at which the +// audit should be written to the store. +// +// O.2's forbidden actions: "Do not write the DB record more than once (only at +// final seal — the buffer carries incremental versions)." research/21 §5 says +// the same from the other side: "The DB write happens once, at seal, with the +// final version — the buffer carries the incremental versions, the knowledge +// base carries the settled one. This keeps regression checking querying stable +// rows." +// +// So this is true exactly on the transition INTO a settled state and false +// forever after, which makes at most one write per audit: +// +// collecting/sast_sealed/dast_sealed -> both_sealed true (the seal) +// collecting/sast_sealed/dast_sealed -> expired true (see below) +// both_sealed -> consumed false (already written) +// both_sealed -> expired false (already written) +// anything -> itself false +// +// WHY EXPIRY ALSO SETTLES. An audit whose claim window closes before both +// halves sealed never reaches record.StateBothSealed, and a rule keyed only on +// that state would leave it with no row at all. plan/40-record-and-storage.md +// is explicit that the reaper "drops the payload and never the row", which +// presupposes a row exists; record.StateExpired is a legal +// `ck_audit_record_state` value for the same reason. This is one write or the +// other, never both, because the two settled states are reached by disjoint +// paths and a settled record never becomes unsettled. +func DurableWriteDue(before, after AuditRecord) bool { + return !settled(before.State) && settled(after.State) +} + +// settled reports whether a state means the audit's durable row is final. +// +// THIS IS A DURABILITY PREDICATE, NOT A READ GATE, and the distinction +// matters because it compares an anvil/state against record.StateExpired, +// which is one arm of the read gate and which internal/record's own source +// guard (TestReadGateArmsAppearOnlyInsideTheGate) exists to keep out of +// readability decisions — and which this package now has its own guard for, +// TestReadGateArmsAreNotReDerivedInThisPackage, whose allowlist names this +// function and acceptsWrites and nothing else. Nothing here decides whether +// anything may be READ: record.Sealer.ReadHalf answers that, Controller.Findings +// is its only caller in this package, and neither consults this function. What +// this answers is "has the store writer's one chance arrived", which record has +// no opinion about because it holds no database handle. +// +// record.StateConsumed is listed for totality. It is unreachable without +// passing through record.StateBothSealed (record.Sealer.Consume refuses every +// other state with record.ErrNotBothSealed), so it never produces a write of +// its own. +func settled(s record.State) bool { + return s == record.StateBothSealed || s == record.StateConsumed || s == record.StateExpired +} + +// acceptsWrites reports whether an audit in state s still accepts new +// findings. +// +// It is this package's mirror of the guard record.Sealer.SealHalf and +// record.Sealer.RecordDastOutcome apply before every mutation ("audit is no +// longer accepting seals", record.ErrAuditTerminal). It exists because +// EventKindFindings buffers in THIS package and so reaches no Sealer entry +// point that could refuse it — without this, findings would keep piling onto +// an expired audit that record has already given up on. +// +// TestAcceptsWritesAgreesWithTheSealer drives a Sealer into all six states and +// asserts this function's answer matches whether SealHalf returns +// record.ErrAuditTerminal, so the mirror cannot drift from the original. +// +// THE MIRROR WAS NEVER THE PROBLEM; THE INPUT WAS. CRITIQUE O.4 finding O4-M2: +// this function was called with the CALLER's `rec.State`, so an audit record had +// already expired kept accepting findings — the exact outcome the paragraph +// above says this exists to prevent. Its callers now pass the state projected +// from a record.AuditSeal taken under the controller's lock. +func acceptsWrites(s record.State) bool { + return !(s == record.StateConsumed || s == record.StateExpired) +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +// auditState is the mutable per-audit state THIS package owns: everything in +// research/21 §5's record shape that record.Sealer does not carry. +// +// It lives on the Controller, behind Controller.mu, and NOT on the AuditRecord +// value callers hold. See the file header for why; the short version is that +// eight workers fanning findings into one audit through caller-owned buffers +// lost seven eighths of them. +type auditState struct { + version int + sast []record.Result + dast []record.Result + correlation []record.Correlation + consumedAt *time.Time + publishedAt time.Time + pendingDast int + + // deadlines is the controller's own copy of the two scan-scoped instants, + // fixed once by DeadlinePolicy.At at Begin. Scheduling reads THIS, never + // the copy on a caller's AuditRecord, so a caller cannot move its own wake + // schedule by handing back an edited snapshot. Neither copy decides + // anything; both due-checks are the Sealer's. + deadlines Deadlines +} + +// clone is the working copy Transition mutates. Committing it is one +// assignment at the very end of Transition, AFTER every path that can return an +// error — which is what makes "a refused transition changes nothing" total +// rather than nearly total (CRITIQUE O.4 finding O4-m1: applyTick used to seal +// the DAST half, bump the version and only then hit an error return, losing the +// bump and leaving the caller's record permanently disagreeing with the Sealer). +func (s *auditState) clone() auditState { + out := *s + out.sast = append([]record.Result(nil), s.sast...) + out.dast = append([]record.Result(nil), s.dast...) + out.correlation = append([]record.Correlation(nil), s.correlation...) + out.consumedAt = copyTime(s.consumedAt) + return out +} + +// Controller is `anvil-scanctl`'s state machine owner: plan/00-SPINE.md S10's +// "one named scan controller with one state machine and one owner, or it will +// be re-implemented inconsistently in four places." +// +// It holds ONE record.Sealer, and that Sealer is the state machine. The +// Controller supplies the three things the Sealer deliberately does not have: +// the configured clocks (deadlines.go), the publication watermarks, and the +// per-audit buffers research/21 §5 puts on the record. +// +// # CONCURRENCY, STATED AS WHAT IT ACTUALLY DOES +// +// It is safe for concurrent use, including fan-in from several workers on ONE +// audit, and this is a guarantee rather than an instruction to the caller. +// `mu` guards the audit map, every `auditState` in it and the clock; the Sealer +// holds its own mutex under that. Transition takes `mu` for the whole event, +// applies it to a working copy and commits with one assignment, so: +// +// - two goroutines handing in the SAME stale AuditRecord both land their +// findings; neither overwrites the other, and the version counter counts +// every publication rather than the last writer's; +// - a refused transition leaves the controller byte-identical; +// - SetClock is safe against a concurrent Transition, which it was not +// (O4-m4: it wrote `c.now` with no lock while four readers read it). +// +// The previous doc said "the failure mode is a skipped version bump, not a +// corrupt lifecycle" and told callers to serialise per audit. The lifecycle +// claim was true — the Sealer's mutex saw to that — but the findings claim was +// not, and the critic measured 21 of 24 findings lost through exactly the shape +// the doc described as safe. Serialising per audit would not have been enough +// either: the loss came from two goroutines cloning one caller-owned value, so +// it survives any amount of external serialisation as long as both hold their +// own copy. TestConcurrentFanInLosesNoFindings is the live probe, and CI runs it +// under -race on Linux (this repository already has one concurrency bug that +// only -race on CI caught: internal/handoff/reaper.go:415). +// +// LOCK ORDER is Controller.mu then record.Sealer.mu, on every path. The Sealer +// never calls back into this package, so there is no second order to conflict +// with. +type Controller struct { + policy DeadlinePolicy // resolved, immutable after NewController + marks WatermarkPolicy // resolved, immutable after NewController + sealer *record.Sealer + + mu sync.Mutex + now func() time.Time + audits map[string]*auditState +} + +// NewController resolves both policies and returns a Controller over a fresh +// record.Sealer. +// +// The watermark interval's default is derived from THIS policy's DAST budget — +// the resolved DAST deadline when the installation has a DAST half, and half +// the claim window otherwise (see DefaultWatermarkInterval). +func NewController(policy DeadlinePolicy, marks WatermarkPolicy) (*Controller, error) { + resolvedPolicy, err := policy.Resolve() + if err != nil { + return nil, err + } + + budget, ok := resolvedPolicy.DastDeadline() + if !ok { + // No DAST half, so no DAST deadline. Half the claim window is the + // same quantity DefaultDastDeadlineSeconds would have produced had + // the tier been installed, which keeps M stable across an + // `anvil-dast` install that accepts the default. + budget = resolvedPolicy.ClaimTimeout() / 2 + } + resolvedMarks, err := marks.Resolve(budget) + if err != nil { + return nil, err + } + + return &Controller{ + policy: resolvedPolicy, + marks: resolvedMarks, + sealer: record.NewSealer(), + now: time.Now, + audits: make(map[string]*auditState), + }, nil +} + +// SetClock replaces the clock used for `anvil/sealedAt`, for the DAST deadline +// due-check, for `consumed_at`, and for the publication watermarks. Passing +// nil restores time.Now. +// +// ONE CLOCK, not two: it is pushed into the Sealer as well, so a test that +// advances time cannot end up with a Sealer stamping wall-clock seals onto a +// record whose watermarks are running on a fake clock. Neither clock affects +// Deadlines, which are a function of scan start alone. +// +// IT TAKES THE MUTEX, for the same reason record.Sealer.SetClock takes its own +// (O4-m4). Construction-time use was always safe; a daemon that re-clocks at +// runtime raced four readers — Transition, applyTick, publish and NextWake — +// and the race detector cannot run on the Windows dev host, so only CI would +// ever have seen it. +func (c *Controller) SetClock(now func() time.Time) { + if now == nil { + now = time.Now + } + c.mu.Lock() + defer c.mu.Unlock() + c.now = now + // Under c.mu, so the documented lock order (Controller.mu then Sealer.mu) + // holds here as it does on every other path, and so a Transition in flight + // cannot observe the controller and the Sealer on two different clocks. + c.sealer.SetClock(now) +} + +// Policy and Watermarks return the resolved configuration, for diagnostics and +// for O.3's adapter. +func (c *Controller) Policy() DeadlinePolicy { return c.policy } +func (c *Controller) Watermarks() WatermarkPolicy { return c.marks } + +// Sealer exposes the ONE record.Sealer this controller owns. +// +// It is exported so that internal/scanctl/handoff.go (O.3) can ask +// record.Sealer.ReadyForConsumption which halves a `handoff` row's +// consumption class may key on, WITHOUT constructing a second Sealer. Two +// Sealers over one audit would be two answers to the read gate, which is the +// defect class internal/record/sealing.go's header is written about. +func (c *Controller) Sealer() *record.Sealer { return c.sealer } + +// Begin registers an audit and returns its version-1 record. +// +// It calls record.Sealer.BeginAudit, which computes `deadline_at` once via +// record.ComputeDeadline and — when DAST is disabled — immediately and +// terminally seals the DAST half as record.HalfStatusSkipped / +// record.DastStatusNotRun, so a core-`anvil` install starts in +// record.StateDastSealed and reaches record.StateBothSealed the moment its +// SAST half seals. Nothing in this file re-derives that. +func (c *Controller) Begin(auditID string, startedAt time.Time) (AuditRecord, error) { + cfg, err := c.policy.AuditConfig(auditID, startedAt) + if err != nil { + return AuditRecord{}, err + } + deadlines, err := c.policy.At(startedAt) + if err != nil { + return AuditRecord{}, err + } + c.mu.Lock() + defer c.mu.Unlock() + + seal, err := c.sealer.BeginAudit(cfg) + if err != nil { + return AuditRecord{}, err + } + + st := &auditState{ + version: 1, // ck_audit_record_audit_version_positive: >= 1 + publishedAt: startedAt, + deadlines: deadlines, + } + c.audits[auditID] = st + return c.snapshotLocked(auditID, st, seal), nil +} + +// Record re-projects an audit the controller already knows: the CURRENT +// lifecycle from record.Sealer, the current buffers and version from here. +// +// It is the refresh path, and its absence is half of CRITIQUE O.4 blocker 1. +// The Controller exposed Policy, Watermarks, Sealer, Begin, Transition and +// NextWake, and nothing that would re-project a record a caller was already +// holding — so a caller that wanted a current answer had no way to ask for one +// except to invent an event. A daemon that wakes, reads and decides should call +// this rather than trust the last AuditRecord it happens to have. +// +// ok is false for an audit this controller never began or has forgotten. +func (c *Controller) Record(auditID string) (AuditRecord, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + st, known := c.audits[auditID] + if !known { + return AuditRecord{}, false + } + seal, sealed := c.sealer.Inspect(auditID) + if !sealed { + return AuditRecord{}, false + } + return c.snapshotLocked(auditID, st, seal), true +} + +// Transition applies one event and returns the resulting record. The input is +// never mutated. +// +// ON REFUSAL it returns the INPUT RECORD UNCHANGED alongside the error, not a +// zero record. The idiom this package expects is `rec, err = ctl.Transition(rec, +// ev)`, and handing back a zero AuditRecord there would turn a refused +// transition into a destroyed one — a caller that mishandles the error would +// lose the audit id, the deadlines and every buffered finding. A refused +// transition changes nothing, in the Sealer or in the returned value. +// +// Every lifecycle field on the result is re-read from record.Sealer after the +// event is applied. Version, findings, correlation and the watermark +// bookkeeping are this package's; State, the per-half statuses and +// `sealedAt`s, and DastStatus are record's. +// +// # ONLY rec.AuditID IS READ FROM rec +// +// Not its state, not its buffers, not its version, not its deadlines. Those all +// live on the Controller and are re-read here under the lock, which is what +// makes concurrent fan-in lossless and what stops a stale snapshot from +// answering a guard. Handing in a record from ten minutes ago applies the event +// to the audit as it stands NOW, and returns the audit as it stands after. +func (c *Controller) Transition(rec AuditRecord, ev Event) (AuditRecord, error) { + c.mu.Lock() + defer c.mu.Unlock() + + st, known := c.audits[rec.AuditID] + before, sealed := c.sealer.Inspect(rec.AuditID) + if !known || !sealed { + return rec, &TransitionError{ + Kind: ev.Kind, AuditID: rec.AuditID, State: rec.State, + Reason: "no such audit in this controller; call Begin first", + Err: record.ErrUnknownAudit, + } + } + + // THE LIVE PROJECTION, taken BEFORE the switch. Every guard below reads its + // State and its per-half statuses from this value and never from `rec`. + // CRITIQUE O.4 finding O4-M2: applyFindings and applyCorrelation guarded on + // the caller's `rec.State`, so findings and correlation landed on an audit + // record had already expired — with acceptsWrites' own doc naming that as + // the thing it existed to prevent. + live := c.snapshotLocked(rec.AuditID, st, before) + + // The working copy. Committed at the bottom, after every error return. + w := st.clone() + + switch ev.Kind { + case EventKindFindings: + if err := c.applyFindings(&w, live, ev); err != nil { + return rec, err + } + case EventKindDastOutcome: + if err := c.sealer.RecordDastOutcome(rec.AuditID, ev.Outcome); err != nil { + return rec, err + } + case EventKindSealHalf: + if err := c.sealer.SealHalf(rec.AuditID, ev.Half, ev.Status); err != nil { + return rec, err + } + // Watermarks (a) and (c): a half reached a terminal status, so the + // record changed materially and the queue must be re-cut. + // + // ONLY IF IT ACTUALLY CHANGED. record.Sealer.SealHalf is documented + // idempotent — "Re-sealing a half with the IDENTICAL status is a no-op + // and preserves the original SealedAt" — and returns nil for that case, + // which this code could not previously tell from a real seal. CRITIQUE + // O.4 finding O4-M1: a redelivered seal event bumped `audit_version`, + // and a bump is not cosmetic. It obliges S6's queue re-cut (R.11) and + // internal/handoff re-checks `audit_record.audit_version` on EVERY + // mutation (claim.go:641), answering handoff.ErrRecordVersionChanged — + // so one duplicated worker message, the ordinary consequence of + // at-least-once delivery, invalidated every in-flight lease on the + // audit for a transition that changed nothing. + after, stillSealed := c.sealer.Inspect(rec.AuditID) + if stillSealed && sealChanged(before, after) { + c.publish(&w) + } + case EventKindTick: + if err := c.applyTick(rec.AuditID, &w); err != nil { + return rec, err + } + case EventKindCorrelate: + if err := c.applyCorrelation(&w, live, ev); err != nil { + return rec, err + } + case EventKindConsume: + if err := c.sealer.Consume(rec.AuditID); err != nil { + return rec, err + } + if w.consumedAt == nil { + // Re-entrant: the first take is the one recorded. + t := c.now().UTC() + w.consumedAt = &t + } + default: + return rec, &TransitionError{ + Kind: ev.Kind, AuditID: rec.AuditID, State: rec.State, + Reason: fmt.Sprintf("legal kinds are %v", EventKindValues()), + Err: ErrUnknownEvent, + } + } + + seal, known := c.sealer.Inspect(rec.AuditID) + if !known { + // Unreachable: nothing in this function forgets an audit, and the + // same lookup succeeded above under the same lock. Reported rather than + // ignored, because silently returning an unprojected record would hand + // the caller a lifecycle this package invented. The working copy is + // discarded, which is the same treatment every other refusal gets. + return rec, &TransitionError{ + Kind: ev.Kind, AuditID: rec.AuditID, State: rec.State, + Reason: "the audit vanished from the sealer mid-transition", + Err: record.ErrUnknownAudit, + } + } + + // THE COMMIT. One assignment, after every error return in this function, so + // "A refused transition changes nothing, in the Sealer or in the returned + // value" is total for this package's own state rather than nearly total. + *st = w + return c.snapshotLocked(rec.AuditID, st, seal), nil +} + +// sealChanged reports whether two record.AuditSeal snapshots of one audit +// describe different lifecycles — i.e. whether the Sealer call between them did +// anything. +// +// It compares field by field rather than with ==, and that is not stylistic: +// record.HalfSeal carries an unexported provenance pointer that is freshly +// allocated on every mint, so `before.Sast == after.Sast` is false for two +// snapshots of an audit that did not move. Comparing the whole struct would +// have made the O4-M1 fix silently no-op. +func sealChanged(before, after record.AuditSeal) bool { + return before.State != after.State || + before.DastStatus != after.DastStatus || + halfSealChanged(before.Sast, after.Sast) || + halfSealChanged(before.Dast, after.Dast) +} + +func halfSealChanged(before, after record.HalfSeal) bool { + if before.Status != after.Status { + return true + } + switch { + case before.SealedAt == nil && after.SealedAt == nil: + return false + case before.SealedAt == nil || after.SealedAt == nil: + return true + default: + return !before.SealedAt.Equal(*after.SealedAt) + } +} + +// applyFindings buffers findings on one half and applies watermark (b)'s count +// arm. It reaches no Sealer entry point, so it carries both refusals itself. +// +// EVERY GUARD READS `live`, WHICH IS THE SEALER'S ANSWER, and never the caller's +// snapshot. `live` was projected from a record.AuditSeal taken moments earlier +// under the same lock. That is finding O4-M2's fix: the mirror (acceptsWrites) +// was always faithful — TestAcceptsWritesAgreesWithTheSealer proves it against +// record.ErrAuditTerminal — and the INPUT was not. +func (c *Controller) applyFindings(w *auditState, live AuditRecord, ev Event) error { + if err := record.ValidateHalf(string(ev.Half)); err != nil { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, Half: ev.Half, State: live.State, + Reason: "illegal anvil/half: " + err.Error(), Err: err, + } + } + if len(ev.Findings) == 0 { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, Half: ev.Half, State: live.State, + Reason: "no findings; a transition that changes nothing is a silent no-op", + Err: ErrEmptyEvent, + } + } + if !acceptsWrites(live.State) { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, Half: ev.Half, State: live.State, + Reason: "the audit is consumed or expired and accepts no further findings", + Err: record.ErrAuditTerminal, + } + } + + status, buffer := live.Sast.Status, &w.sast + if ev.Half == record.HalfDast { + status, buffer = live.Dast.Status, &w.dast + } + if record.IsTerminalHalfStatus(status) { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, Half: ev.Half, State: live.State, + Reason: fmt.Sprintf("the half is %q; its results are frozen", status), + Err: ErrHalfNotAccepting, + } + } + + *buffer = append(*buffer, ev.Findings...) + + if ev.Half == record.HalfDast { + w.pendingDast += len(ev.Findings) + if w.pendingDast >= c.marks.DastFindings { + c.publish(w) // watermark (b), count arm + } + } + // The SAST half does not have a count watermark. research/21 §5 scopes + // (b) to DAST findings, and reason 2 says why: "DAST's tail is the + // enemy... over three orders of magnitude of duration variance." A SAST + // half is a bounded batch that publishes once, at its seal, under (a). + return nil +} + +// applyTick drives clock 3, then clock 2, then this package's own watermark +// bookkeeping. The order of the two CLOCKS is load-bearing; the bookkeeping is +// last for a different reason. +// +// Clock 3 FIRST, because record.Sealer refuses a seal on an audit that has +// already reached record.StateExpired. Expiring first would mean a DAST half +// whose deadline and whose claim window fell on the same wake never records +// that it timed out, and `anvil/dastStatus` would keep the value it held +// while running. +// +// NEITHER DUE-CHECK IS THIS PACKAGE'S. Clock 2's is +// record.Sealer.ExpireIfDue; clock 3's is record.Sealer.SealDastIfDeadlineDue, +// which internal/record added for exactly this reason. Both compare against +// the audit's own `startedAt` plus the offsets record.Sealer.BeginAudit fixed, +// so neither can be moved by anything a caller holds. CRITIQUE O.4 blocker 2: +// this function used to read clock 3 out of `AuditRecord.Deadlines`, an +// exported field on the caller's value, and probe P10 moved the DAST deadline +// by assigning to it — the forced seal never fired and the half stayed +// `running` past its budget. +// +// THE BOOKKEEPING RUNS AFTER EVERY ERROR RETURN. Finding O4-m1: the old +// order sealed the DAST half, bumped the version, and only then called a +// function that could return an error — on which path the bump was discarded +// while the Sealer kept the seal. Publication is pure local arithmetic and +// neither clock's outcome depends on it, so moving it below both calls costs +// nothing and makes Transition's "a refused transition changes nothing" +// honest. +// +// A tick is IDEMPOTENT and a tick on a settled audit is not an error. Daemons +// wake on a schedule; making a routine wake fail would mean every expired +// audit logs an error forever. Both Sealer calls return (false, nil) — not an +// error — for every ordinary reason to do nothing. +func (c *Controller) applyTick(auditID string, w *auditState) error { + // Clock 3 — the DAST deadline. record.Sealer.SealDastIfDeadlineDue both + // decides and seals, so there is no window in which this package has been + // told "due" and has not yet acted, and no second opinion about what "due" + // means. It seals with record.HalfStatusTimedOut and lets + // record.DeriveDastStatus decide the audit-level `anvil/dastStatus` from + // that plus the target's provenance. + dastTimedOut, err := c.sealer.SealDastIfDeadlineDue(auditID) + if err != nil { + return err + } + + // Clock 2 — the claim timeout. + if _, err := c.sealer.ExpireIfDue(auditID); err != nil { + return err + } + + switch { + case dastTimedOut: + // Watermark (c): a half reached a terminal status. + c.publish(w) + case w.pendingDast > 0 && !c.now().Before(w.publishedAt.Add(c.marks.Interval)): + // Watermark (b), time arm: M elapsed with DAST findings still + // unpublished. Mutually exclusive with the clause above, because that + // one zeroes the pending counter. + c.publish(w) + } + return nil +} + +// applyCorrelation stores R.12's clusters. It is not a watermark. +// +// # A BATCH REPLACES; IT DOES NOT ACCUMULATE +// +// This is the contract, stated because CRITIQUE O.4 finding O4-m3 observed that +// nothing stated it. research/21 §5 describes correlation as "populated as both +// sides land", which reads incremental, and this function assigns rather than +// appends — so a correlator emitting a SAST-side batch and then a DAST-side +// batch would lose the first. +// +// The contract is REPLACEMENT, and the reason is that the alternative cannot be +// made correct here. A cluster is a statement about which SAST and DAST findings +// are the same issue; appending two batches would produce duplicate cluster ids +// and no rule for reconciling a cluster whose membership grew, and this package +// has no correlation vocabulary with which to write that rule (R.12 owns it). +// Replacement makes the correlator's own latest answer the answer, which is a +// contract it can honour by emitting the complete set each time — and one whose +// violation is visible (clusters disappear) rather than silent (clusters +// duplicate). +// +// CorrelateEvent's doc states the same thing from the caller's side. +// TestACorrelationBatchReplacesTheWholeSet is the regression. +func (c *Controller) applyCorrelation(w *auditState, live AuditRecord, ev Event) error { + if len(ev.Correlation) == 0 { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, State: live.State, + Reason: "no clusters; a transition that changes nothing is a silent no-op", + Err: ErrEmptyEvent, + } + } + // The Sealer's answer, not the caller's; see applyFindings (O4-M2). + if !acceptsWrites(live.State) { + return &TransitionError{ + Kind: ev.Kind, AuditID: live.AuditID, State: live.State, + Reason: "the audit is consumed or expired and accepts no further correlation", + Err: record.ErrAuditTerminal, + } + } + w.correlation = append([]record.Correlation(nil), ev.Correlation...) + return nil +} + +// publish is the ONE version bump. Every watermark routes through it, so the +// three bookkeeping fields can never disagree about whether a publication +// happened. It requires c.mu. +func (c *Controller) publish(w *auditState) { + w.version++ + w.publishedAt = c.now().UTC() + w.pendingDast = 0 +} + +// snapshotLocked projects (this package's state, record.Sealer's answer) onto +// the AuditRecord value callers hold. It requires c.mu. +// +// Every slice and every optional instant is COPIED, so nothing a caller does to +// a snapshot can reach the controller's state or another snapshot of it. +func (c *Controller) snapshotLocked(auditID string, st *auditState, seal record.AuditSeal) AuditRecord { + rec := AuditRecord{ + AuditID: auditID, + Version: st.version, + Deadlines: st.deadlines, + Correlation: append([]record.Correlation(nil), st.correlation...), + ConsumedAt: copyTime(st.consumedAt), + PublishedAt: st.publishedAt, + PendingDastFindings: st.pendingDast, + } + rec.Sast.findings = append([]record.Result(nil), st.sast...) + rec.Dast.findings = append([]record.Result(nil), st.dast...) + return project(seal, rec) +} + +// project overwrites every lifecycle field with record.Sealer's answer. +// +// It is the reason this file cannot drift from the frozen state machine: there +// is no code path on which State, a per-half Status or SealedAt, or DastStatus +// is assigned from anything but a record.AuditSeal. +func project(seal record.AuditSeal, out AuditRecord) AuditRecord { + out.State = seal.State + out.DastStatus = seal.DastStatus + out.Sast.Half = record.HalfSast + out.Sast.Status = seal.Sast.Status + out.Sast.SealedAt = copyTime(seal.Sast.SealedAt) + out.Dast.Half = record.HalfDast + out.Dast.Status = seal.Dast.Status + out.Dast.SealedAt = copyTime(seal.Dast.SealedAt) + return out +} + +// --------------------------------------------------------------------------- +// The result surface — CRITIQUE O.4 blocker 1 +// --------------------------------------------------------------------------- + +// Findings returns a copy of one half's `findings[]`, gated by the ONE read +// gate, over a seal MINTED BY ITS PRODUCER against the audit as it stands now. +// +// It is a Controller method and not an AuditRecord method, and that is the whole +// point. The gate's answer is only worth anything if the value it is asked about +// describes a real half of a real record NOW; the previous version assembled a +// record.HalfSeal from two fields of the caller's own snapshot, which meant the +// gate answered honestly about a record that had stopped existing. Probe P3 read +// findings out of an EXPIRED audit that way. +// +// The seal here comes from record.Sealer.ReadHalf, which is one of the two +// legitimate producers of seal provenance and applies record.HalfReadGate to +// what it mints. So both arms are the frozen package's, the subject is the live +// audit, and this package neither builds a seal nor re-derives a predicate. +// A refusal is record's own: it satisfies errors.Is(err, record.ErrHalfNotSealed) +// and names the arm that shut — a status that is not sealed, or an expired audit +// whose payload the reaper has dropped. +// +// The findings themselves come from the controller's buffer rather than from +// `rec`, so a caller holding an old snapshot gets the current results or a +// refusal, never a silently truncated read. `rec` supplies the audit id and +// nothing else. +// +// On refusal the returned slice is nil and carries nothing. +func (c *Controller) Findings(rec AuditRecord, half record.Half) ([]record.Result, error) { + c.mu.Lock() + defer c.mu.Unlock() + + st, err := c.readGateLocked(rec, half) + if err != nil { + return nil, err + } + src := st.sast + if half == record.HalfDast { + src = st.dast + } + out := make([]record.Result, len(src)) + copy(out, src) + return out, nil +} + +// Readable reports whether a consumer may read this half's results. +// +// It is Findings' own gate as a bool — literally the same function body — so a +// caller that branches and a caller that reports a typed refusal can never +// disagree. TestReadableAgreesWithFindingsEverywhere asserts that across every +// state and both halves. +func (c *Controller) Readable(rec AuditRecord, half record.Half) bool { + c.mu.Lock() + defer c.mu.Unlock() + + _, err := c.readGateLocked(rec, half) + return err == nil +} + +// readGateLocked is the one gate call, shared by Findings and Readable. It +// requires c.mu and returns the audit's state only when the gate is open. +func (c *Controller) readGateLocked(rec AuditRecord, half record.Half) (*auditState, error) { + if err := record.ValidateHalf(string(half)); err != nil { + return nil, &TransitionError{ + AuditID: rec.AuditID, Half: half, State: rec.State, + Reason: "illegal anvil/half: " + err.Error(), Err: err, + } + } + st, known := c.audits[rec.AuditID] + if !known { + return nil, &TransitionError{ + AuditID: rec.AuditID, Half: half, State: rec.State, + Reason: "no such audit in this controller; call Begin first", + Err: record.ErrUnknownAudit, + } + } + // THE GATE. Producer-minted seal, live audit, record's own predicate. + if _, err := c.sealer.ReadHalf(rec.AuditID, half); err != nil { + return nil, err + } + return st, nil +} + +// NextWake returns the earliest instant at which this controller has something +// to do for rec, for a daemon timer to sleep until. ok is false when there is +// nothing left to wait for. +// +// It is Deadlines.NextWake — the two deadline instants — WIDENED by watermark +// (b)'s time arm, which Deadlines cannot know about because M is not a +// deadline. A daemon that slept only on Deadlines.NextWake would hold DAST +// findings unpublished for up to the whole DAST budget whenever fewer than N +// of them arrived, which is the staleness bound M exists to cap. +// +// IT IS A SCHEDULING ANSWER, NOT A DECISION, and deadlines.go's warning +// applies unchanged: a caller that infers "the returned instant is the DAST +// deadline, therefore the DAST half has not timed out" has substituted a +// scheduling hint for a due-check. +// +// It reads the CONTROLLER's deadlines and watermark bookkeeping, not `rec`'s. +// `rec` supplies the audit id. A caller cannot advance or delay its own wake by +// handing back an edited snapshot, and an unknown audit has nothing to wait for. +func (c *Controller) NextWake(rec AuditRecord) (time.Time, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + st, known := c.audits[rec.AuditID] + if !known { + return time.Time{}, false + } + + now := c.now() + next, found := st.deadlines.NextWake(now) + + if st.pendingDast > 0 { + if at := st.publishedAt.Add(c.marks.Interval); at.After(now) { + if !found || at.Before(next) { + next, found = at, true + } + } + } + return next, found +} diff --git a/internal/scanctl/statemachine_test.go b/internal/scanctl/statemachine_test.go new file mode 100644 index 0000000..c410b44 --- /dev/null +++ b/internal/scanctl/statemachine_test.go @@ -0,0 +1,1360 @@ +package scanctl + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/Susquehanna-Syntax/Anvil/internal/record" +) + +// baseTime is every test's `scan_run.started_at`. Both scan-scoped clocks are +// anchored to it, so every instant below is written as an offset from it and +// the arithmetic in a failure message is legible. +var baseTime = time.Date(2026, 8, 7, 9, 0, 0, 0, time.UTC) + +// testClock is a settable clock shared by the Controller and, via +// Controller.SetClock, by the record.Sealer underneath it. One clock: a test +// that advances time must not leave the Sealer stamping wall-clock seals. +type testClock struct{ at time.Time } + +func (c *testClock) now() time.Time { return c.at } + +func (c *testClock) set(d time.Duration) { c.at = baseTime.Add(d) } + +// dastPolicy is the shipped-default policy WITH the DAST tier installed: an 8h +// claim window and a derived 4h DAST deadline, which binds. +func dastPolicy() DeadlinePolicy { return DeadlinePolicy{DastEnabled: true} } + +// sastOnlyPolicy is the core `anvil` artifact (plan/00-SPINE.md S9-AMENDED): +// no `anvil-dast`, so no DAST half and no clock 3. +func sastOnlyPolicy() DeadlinePolicy { return DeadlinePolicy{} } + +func newTestController(t *testing.T, p DeadlinePolicy, m WatermarkPolicy) (*Controller, *testClock) { + t.Helper() + ctl, err := NewController(p, m) + if err != nil { + t.Fatalf("NewController: %v", err) + } + clk := &testClock{at: baseTime} + ctl.SetClock(clk.now) + return ctl, clk +} + +func mustBegin(t *testing.T, ctl *Controller, auditID string) AuditRecord { + t.Helper() + rec, err := ctl.Begin(auditID, baseTime) + if err != nil { + t.Fatalf("Begin(%q): %v", auditID, err) + } + return rec +} + +func mustTransition(t *testing.T, ctl *Controller, rec AuditRecord, ev Event) AuditRecord { + t.Helper() + out, err := ctl.Transition(rec, ev) + if err != nil { + t.Fatalf("Transition(%s) from state %s: %v", ev.Kind, rec.State, err) + } + return out +} + +func finding(rule string) record.Result { + return record.Result{RuleID: rule, Message: record.Message{Text: rule}} +} + +func findings(n int) []record.Result { + out := make([]record.Result, n) + for i := range out { + out[i] = finding(fmt.Sprintf("anvil.test.rule/%d", i)) + } + return out +} + +// bootedCleanOutcome is what the target lifecycle harness reports for a target +// that came up. Without it the default provenance is +// record.TargetProvenanceNoTargetDeclared, which record.DeriveDastStatus maps +// to record.DastStatusSkippedNoManifest regardless of the half's own status — +// deliberately, so an audit nobody reported a target for can never land on +// record.DastStatusCompletedClean by omission. +func bootedCleanOutcome() record.DastOutcome { + return record.DastOutcome{ + TierInstalled: true, + Provenance: record.TargetProvenanceBootedClean, + } +} + +// --------------------------------------------------------------------------- +// Begin +// --------------------------------------------------------------------------- + +func TestBeginStartsAtVersionOne(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-begin") + + if rec.Version != 1 { + t.Errorf("Version = %d, want 1 (ck_audit_record_audit_version_positive requires >= 1)", rec.Version) + } + if rec.State != record.StateCollecting { + t.Errorf("State = %q, want %q", rec.State, record.StateCollecting) + } + if rec.Sast.Status != record.HalfStatusRunning || rec.Dast.Status != record.HalfStatusRunning { + t.Errorf("halves = (%q, %q), want both %q", rec.Sast.Status, rec.Dast.Status, record.HalfStatusRunning) + } + if rec.DastStatus != record.DastStatusRunning { + t.Errorf("DastStatus = %q, want %q", rec.DastStatus, record.DastStatusRunning) + } + if got, want := rec.Deadlines.DeadlineAt(), baseTime.Add(8*time.Hour); !got.Equal(want) { + t.Errorf("DeadlineAt = %s, want %s", got, want) + } + if at, ok := rec.Deadlines.DastDeadline(); !ok || !at.Equal(baseTime.Add(4*time.Hour)) { + t.Errorf("DastDeadline = (%s, %v), want (%s, true)", at, ok, baseTime.Add(4*time.Hour)) + } + if !rec.Deadlines.DastDeadlineBinds() { + t.Error("the shipped defaults must bind: 4h < 8h") + } + if !rec.PublishedAt.Equal(baseTime) { + t.Errorf("PublishedAt = %s, want scan start %s (version 1 is itself a publication)", rec.PublishedAt, baseTime) + } +} + +// A core `anvil` install has no `anvil-dast`, so record.Sealer.BeginAudit +// terminally seals the DAST half at scan start. The audit starts in +// record.StateDastSealed — which is one of the states O.2's struck four-state +// machine could not express at all. +func TestBeginWithoutDastTierStartsDastSealed(t *testing.T) { + ctl, _ := newTestController(t, sastOnlyPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-sast-only") + + if rec.State != record.StateDastSealed { + t.Errorf("State = %q, want %q", rec.State, record.StateDastSealed) + } + if rec.Dast.Status != record.HalfStatusSkipped { + t.Errorf("Dast.Status = %q, want %q", rec.Dast.Status, record.HalfStatusSkipped) + } + if rec.DastStatus != record.DastStatusNotRun { + t.Errorf("DastStatus = %q, want %q", rec.DastStatus, record.DastStatusNotRun) + } + if _, ok := rec.Deadlines.DastDeadline(); ok { + t.Error("a DAST-disabled audit must have no clock 3") + } + + // ...and it reaches both_sealed the moment SAST seals, with no DAST + // worker in the process. Without that, every SAST-only audit wedges. + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if rec.State != record.StateBothSealed { + t.Fatalf("State = %q, want %q", rec.State, record.StateBothSealed) + } + // TERMINAL IS NOT READABLE: the DAST half advanced the audit but there + // are no DAST results, and "no findings recorded" is not "scanned clean". + if ctl.Readable(rec, record.HalfDast) { + t.Error("a skipped DAST half must never be readable") + } + if !ctl.Readable(rec, record.HalfSast) { + t.Error("a sealed SAST half must be readable") + } +} + +func TestBeginRejectsZeroScanStart(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + if _, err := ctl.Begin("audit-zero", time.Time{}); !errors.Is(err, ErrZeroScanStart) { + t.Fatalf("Begin(zero time) error = %v, want ErrZeroScanStart", err) + } +} + +func TestBeginRefusesDuplicateAudit(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + mustBegin(t, ctl, "audit-dup") + if _, err := ctl.Begin("audit-dup", baseTime.Add(time.Hour)); !errors.Is(err, record.ErrDuplicateAudit) { + t.Fatalf("second Begin error = %v, want record.ErrDuplicateAudit (re-beginning would recompute deadline_at)", err) + } +} + +// --------------------------------------------------------------------------- +// Every legal transition +// --------------------------------------------------------------------------- + +// TestLegalTransitions drives one audit per row through a sequence of events +// and asserts the resulting anvil/state, the two per-half statuses and the +// audit-level anvil/dastStatus. Every arrow into each of R.1's six states +// appears at least once. +func TestLegalTransitions(t *testing.T) { + type step struct { + ev Event + advance time.Duration // clock offset from scan start BEFORE the event + } + tests := []struct { + name string + policy DeadlinePolicy + steps []step + wantState record.State + wantSast record.HalfStatus + wantDast record.HalfStatus + wantDastSt record.DastStatus + }{ + { + name: "collecting: nothing has sealed", + policy: dastPolicy(), + steps: []step{ + {ev: FindingsEvent(record.HalfSast, finding("a"))}, + {ev: DastOutcomeEvent(bootedCleanOutcome())}, + }, + wantState: record.StateCollecting, wantSast: record.HalfStatusRunning, + wantDast: record.HalfStatusRunning, wantDastSt: record.DastStatusRunning, + }, + { + name: "collecting -> sast_sealed: SAST seals first, DAST still running", + policy: dastPolicy(), + steps: []step{ + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + }, + wantState: record.StateSastSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusRunning, wantDastSt: record.DastStatusRunning, + }, + { + name: "collecting -> sast_sealed on a FAILED SAST half", + policy: dastPolicy(), + steps: []step{ + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusFailed)}, + }, + wantState: record.StateSastSealed, wantSast: record.HalfStatusFailed, + wantDast: record.HalfStatusRunning, wantDastSt: record.DastStatusRunning, + }, + { + name: "collecting -> dast_sealed: THE DAST-FIRST SEAL the struck machine could not express", + policy: dastPolicy(), + steps: []step{ + {ev: DastOutcomeEvent(bootedCleanOutcome())}, + {ev: SealHalfEvent(record.HalfDast, record.HalfStatusSealed)}, + }, + wantState: record.StateDastSealed, wantSast: record.HalfStatusRunning, + wantDast: record.HalfStatusSealed, wantDastSt: record.DastStatusCompletedClean, + }, + { + name: "dast_sealed -> both_sealed", + policy: dastPolicy(), + steps: []step{ + {ev: DastOutcomeEvent(record.DastOutcome{ + TierInstalled: true, + Provenance: record.TargetProvenanceBootedClean, + FindingCount: 3, + })}, + {ev: FindingsEvent(record.HalfDast, findings(3)...)}, + {ev: SealHalfEvent(record.HalfDast, record.HalfStatusSealed)}, + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + }, + wantState: record.StateBothSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusSealed, wantDastSt: record.DastStatusCompletedFindings, + }, + { + name: "sast_sealed -> both_sealed", + policy: dastPolicy(), + steps: []step{ + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + {ev: DastOutcomeEvent(bootedCleanOutcome())}, + {ev: SealHalfEvent(record.HalfDast, record.HalfStatusSealed)}, + }, + wantState: record.StateBothSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusSealed, wantDastSt: record.DastStatusCompletedClean, + }, + { + name: "both_sealed -> consumed", + policy: sastOnlyPolicy(), + steps: []step{ + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + {ev: ConsumeEvent()}, + }, + wantState: record.StateConsumed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusSkipped, wantDastSt: record.DastStatusNotRun, + }, + { + name: "collecting -> expired: the claim window closed with nothing sealed", + policy: sastOnlyPolicy(), + steps: []step{ + {ev: TickEvent(), advance: 8 * time.Hour}, + }, + // A SAST-only audit starts in dast_sealed, so "nothing sealed" + // here means the SAST half never sealed. + wantState: record.StateExpired, wantSast: record.HalfStatusRunning, + wantDast: record.HalfStatusSkipped, wantDastSt: record.DastStatusNotRun, + }, + { + name: "clock 3 forces the DAST half terminal: timed_out, not stuck", + policy: dastPolicy(), + steps: []step{ + {ev: DastOutcomeEvent(bootedCleanOutcome())}, + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + {ev: TickEvent(), advance: 4 * time.Hour}, + }, + wantState: record.StateBothSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusTimedOut, wantDastSt: record.DastStatusTimedOut, + }, + { + name: "a DAST half that broke against a booted target is completed_failed, not completed_partial", + policy: dastPolicy(), + steps: []step{ + {ev: DastOutcomeEvent(bootedCleanOutcome())}, + {ev: SealHalfEvent(record.HalfDast, record.HalfStatusFailed)}, + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + }, + wantState: record.StateBothSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusFailed, wantDastSt: record.DastStatusCompletedFailed, + }, + { + name: "a target that failed to boot is distinguishable from scanned clean", + policy: dastPolicy(), + steps: []step{ + {ev: DastOutcomeEvent(record.DastOutcome{ + TierInstalled: true, + Provenance: record.TargetProvenanceBootFailed, + })}, + {ev: SealHalfEvent(record.HalfDast, record.HalfStatusSealed)}, + {ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed)}, + }, + wantState: record.StateBothSealed, wantSast: record.HalfStatusSealed, + wantDast: record.HalfStatusSealed, wantDastSt: record.DastStatusTargetBootFailed, + }, + { + name: "correlation lands without advancing the lifecycle", + policy: dastPolicy(), + steps: []step{ + {ev: CorrelateEvent(record.Correlation{ClusterID: "c1", Role: record.HalfSast})}, + }, + wantState: record.StateCollecting, wantSast: record.HalfStatusRunning, + wantDast: record.HalfStatusRunning, wantDastSt: record.DastStatusRunning, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl, clk := newTestController(t, tt.policy, WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-"+tt.name) + for i, s := range tt.steps { + if s.advance != 0 { + clk.set(s.advance) + } + var err error + rec, err = ctl.Transition(rec, s.ev) + if err != nil { + t.Fatalf("step %d (%s): %v", i, s.ev.Kind, err) + } + } + if rec.State != tt.wantState { + t.Errorf("State = %q, want %q", rec.State, tt.wantState) + } + if rec.Sast.Status != tt.wantSast { + t.Errorf("Sast.Status = %q, want %q", rec.Sast.Status, tt.wantSast) + } + if rec.Dast.Status != tt.wantDast { + t.Errorf("Dast.Status = %q, want %q", rec.Dast.Status, tt.wantDast) + } + if rec.DastStatus != tt.wantDastSt { + t.Errorf("DastStatus = %q, want %q", rec.DastStatus, tt.wantDastSt) + } + if !rec.State.Valid() { + t.Errorf("State %q is not one of R.1's six frozen literals", rec.State) + } + if !rec.DastStatus.Valid() || rec.DastStatus == "" { + t.Errorf("DastStatus %q is not a legal literal; audit_record.dast_status is NOT NULL", rec.DastStatus) + } + }) + } +} + +// Every one of R.1's six anvil/state values must be reachable through this +// controller. The struck four-state machine made `consumed` unreachable by +// making `sealed` terminal; this is the regression test for that ruling. +func TestEverySixthStateIsReachable(t *testing.T) { + reached := map[record.State]bool{} + for _, s := range record.StateValues() { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec, err := driveToState(t, ctl, clk, s) + if err != nil { + t.Errorf("state %q: %v", s, err) + continue + } + if rec.State != s { + t.Errorf("driveToState(%q) landed in %q", s, rec.State) + continue + } + reached[s] = true + } + for _, s := range record.StateValues() { + if !reached[s] { + t.Errorf("anvil/state %q is unreachable through the controller", s) + } + } +} + +// driveToState takes a freshly-begun audit to the named state. It is shared by +// the reachability test and by the illegal-transition table, so the two cannot +// disagree about how a state is reached. +func driveToState(t *testing.T, ctl *Controller, clk *testClock, want record.State) (AuditRecord, error) { + t.Helper() + rec := mustBegin(t, ctl, "audit-drive-"+string(want)) + + switch want { + case record.StateCollecting: + return rec, nil + case record.StateSastSealed: + return ctl.Transition(rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + case record.StateDastSealed: + rec, err := ctl.Transition(rec, DastOutcomeEvent(bootedCleanOutcome())) + if err != nil { + return rec, err + } + return ctl.Transition(rec, SealHalfEvent(record.HalfDast, record.HalfStatusSealed)) + case record.StateBothSealed, record.StateConsumed: + rec, err := ctl.Transition(rec, DastOutcomeEvent(bootedCleanOutcome())) + if err != nil { + return rec, err + } + rec, err = ctl.Transition(rec, SealHalfEvent(record.HalfDast, record.HalfStatusSealed)) + if err != nil { + return rec, err + } + rec, err = ctl.Transition(rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if err != nil || want == record.StateBothSealed { + return rec, err + } + return ctl.Transition(rec, ConsumeEvent()) + case record.StateExpired: + clk.set(8 * time.Hour) + return ctl.Transition(rec, TickEvent()) + } + return rec, fmt.Errorf("no route defined to %q", want) +} + +// --------------------------------------------------------------------------- +// Every illegal transition — an error, never a panic and never a silent no-op +// --------------------------------------------------------------------------- + +func TestIllegalTransitionsReturnErrors(t *testing.T) { + tests := []struct { + name string + from record.State + ev Event + // wantErr is the sentinel errors.Is must reach. Leave nil and set + // wantEnumField when the refusal is a *record.EnumError instead — + // internal/record reports an illegal enum literal with a typed error + // naming the field and every legal value, not with a sentinel. + wantErr error + wantEnumField string + }{ + { + name: "sealing a half as running is not a seal", + from: record.StateCollecting, + ev: SealHalfEvent(record.HalfSast, record.HalfStatusRunning), + // `complete` is struck; `running` is the non-terminal value this + // catches. + wantErr: record.ErrNotSealable, + }, + { + name: "sealing an unknown half", + from: record.StateCollecting, + ev: SealHalfEvent(record.Half("both"), record.HalfStatusSealed), + wantEnumField: "anvil/half", + }, + { + name: "sealing with a status outside the frozen enum", + from: record.StateCollecting, + // `complete` is exactly the struck token ruling G5 removed. It + // must be refused, not silently accepted as a seal. + ev: SealHalfEvent(record.HalfSast, record.HalfStatus("complete")), + wantEnumField: "anvil/status", + }, + { + name: "re-sealing a sealed half with a different status", + from: record.StateSastSealed, + ev: SealHalfEvent(record.HalfSast, record.HalfStatusFailed), + wantErr: record.ErrHalfAlreadySealed, + }, + { + name: "sealing a half of a consumed audit", + from: record.StateConsumed, + ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed), + wantErr: record.ErrAuditTerminal, + }, + { + name: "sealing a half of an expired audit", + from: record.StateExpired, + ev: SealHalfEvent(record.HalfSast, record.HalfStatusSealed), + wantErr: record.ErrAuditTerminal, + }, + { + name: "consuming before both halves sealed", + from: record.StateSastSealed, + ev: ConsumeEvent(), + wantErr: record.ErrNotBothSealed, + }, + { + name: "consuming a collecting audit", + from: record.StateCollecting, + ev: ConsumeEvent(), + wantErr: record.ErrNotBothSealed, + }, + { + name: "consuming an expired audit", + from: record.StateExpired, + ev: ConsumeEvent(), + wantErr: record.ErrAuditTerminal, + }, + { + name: "findings for a half that already sealed", + from: record.StateSastSealed, + ev: FindingsEvent(record.HalfSast, finding("late")), + wantErr: ErrHalfNotAccepting, + }, + { + name: "findings for an expired audit", + from: record.StateExpired, + ev: FindingsEvent(record.HalfSast, finding("late")), + wantErr: record.ErrAuditTerminal, + }, + { + name: "findings for a consumed audit", + from: record.StateConsumed, + ev: FindingsEvent(record.HalfSast, finding("late")), + wantErr: record.ErrAuditTerminal, + }, + { + name: "a findings event carrying no findings", + from: record.StateCollecting, + ev: FindingsEvent(record.HalfSast), + wantErr: ErrEmptyEvent, + }, + { + name: "findings for an unknown half", + from: record.StateCollecting, + ev: FindingsEvent(record.Half("host"), finding("x")), + wantEnumField: "anvil/half", + }, + { + name: "a correlate event carrying no clusters", + from: record.StateCollecting, + ev: CorrelateEvent(), + wantErr: ErrEmptyEvent, + }, + { + name: "correlation for an expired audit", + from: record.StateExpired, + ev: CorrelateEvent(record.Correlation{ClusterID: "c1"}), + wantErr: record.ErrAuditTerminal, + }, + { + name: "a DAST outcome for a sealed DAST half", + from: record.StateDastSealed, + ev: DastOutcomeEvent(bootedCleanOutcome()), + wantErr: record.ErrHalfAlreadySealed, + }, + { + name: "a DAST outcome for an expired audit", + from: record.StateExpired, + ev: DastOutcomeEvent(bootedCleanOutcome()), + wantErr: record.ErrAuditTerminal, + }, + { + name: "the zero Event", + from: record.StateCollecting, + ev: Event{}, + wantErr: ErrUnknownEvent, + }, + { + name: "an invented event kind", + from: record.StateCollecting, + ev: Event{Kind: EventKind("seal")}, + wantErr: ErrUnknownEvent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + before, err := driveToState(t, ctl, clk, tt.from) + if err != nil { + t.Fatalf("driving to %q: %v", tt.from, err) + } + + after, err := ctl.Transition(before, tt.ev) + if err == nil { + t.Fatalf("Transition(%s) from %q succeeded; want a refusal (%v%s)", + tt.ev.Kind, tt.from, tt.wantErr, tt.wantEnumField) + } + switch { + case tt.wantEnumField != "": + var ee *record.EnumError + if !errors.As(err, &ee) { + t.Fatalf("Transition(%s) error = %v, want a *record.EnumError", tt.ev.Kind, err) + } + if ee.Field != tt.wantEnumField { + t.Fatalf("EnumError.Field = %q, want %q", ee.Field, tt.wantEnumField) + } + case !errors.Is(err, tt.wantErr): + t.Fatalf("Transition(%s) error = %v, want errors.Is(..., %v)", tt.ev.Kind, err, tt.wantErr) + } + // NOT A SILENT NO-OP, and not a destroyed record either: the + // returned value is the input, unchanged. + if after.AuditID != before.AuditID || after.State != before.State || + after.Version != before.Version || + after.Sast.FindingCount() != before.Sast.FindingCount() || + after.Dast.FindingCount() != before.Dast.FindingCount() { + t.Errorf("a refused transition changed the record: before=%+v after=%+v", before, after) + } + }) + } +} + +func TestTransitionOnAnUnknownAudit(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + _, err := ctl.Transition(AuditRecord{AuditID: "never-begun"}, TickEvent()) + if !errors.Is(err, record.ErrUnknownAudit) { + t.Fatalf("error = %v, want record.ErrUnknownAudit", err) + } + // The zero AuditRecord is also unknown, so a caller that forgot Begin + // gets a refusal rather than a phantom audit. + if _, err := ctl.Transition(AuditRecord{}, TickEvent()); !errors.Is(err, record.ErrUnknownAudit) { + t.Fatalf("zero record error = %v, want record.ErrUnknownAudit", err) + } +} + +// --------------------------------------------------------------------------- +// SAST must not block on DAST — research/21 §5's whole point +// --------------------------------------------------------------------------- + +func TestSastSealsAndIsConsumableWhileDastRuns(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-nonblocking") + + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, findings(4)...)) + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + + if rec.State != record.StateSastSealed { + t.Fatalf("State = %q, want %q", rec.State, record.StateSastSealed) + } + if rec.Dast.Status != record.HalfStatusRunning { + t.Fatalf("Dast.Status = %q; the DAST half must be untouched", rec.Dast.Status) + } + + // The SAST half's results are readable NOW, with DAST still running. + got, err := ctl.Findings(rec, record.HalfSast) + if err != nil { + t.Fatalf("Findings(sast): %v", err) + } + if len(got) != 4 { + t.Errorf("len(sast findings) = %d, want 4", len(got)) + } + + // And the DAST half's are not — R.6's read gate, asked through + // record.HalfReadGate and not re-derived here. + if _, err := ctl.Findings(rec, record.HalfDast); !errors.Is(err, record.ErrHalfNotSealed) { + t.Errorf("Findings(dast) error = %v, want record.ErrHalfNotSealed", err) + } + + // The consumption gate the handoff table keys on agrees, because it is + // the same gate: record.Sealer.ReadyForConsumption. + sastReady, dastReady := ctl.Sealer().ReadyForConsumption(rec.AuditID) + if !sastReady { + t.Error("ReadyForConsumption reports the sealed SAST half unready; static_only work would never be claimable") + } + if dastReady { + t.Error("ReadyForConsumption reports a still-running DAST half ready") + } +} + +// The packet's stop condition, in its two shapes. A slow or never-terminating +// DAST run must never leave a stuck record. +func TestSlowDastNeverLeavesTheRecordStuck(t *testing.T) { + t.Run("binding DAST deadline: sast_sealed then both_sealed at clock 3", func(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-slow-binding") + rec = mustTransition(t, ctl, rec, DastOutcomeEvent(bootedCleanOutcome())) + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if rec.State != record.StateSastSealed { + t.Fatalf("State = %q, want %q", rec.State, record.StateSastSealed) + } + + // Ticks before the DAST deadline change nothing. + clk.set(3 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if rec.State != record.StateSastSealed { + t.Fatalf("State = %q at t+3h, want %q (clock 3 is at t+4h)", rec.State, record.StateSastSealed) + } + + clk.set(4 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if rec.State != record.StateBothSealed { + t.Fatalf("State = %q at t+4h, want %q", rec.State, record.StateBothSealed) + } + if rec.Dast.Status != record.HalfStatusTimedOut { + t.Errorf("Dast.Status = %q, want %q", rec.Dast.Status, record.HalfStatusTimedOut) + } + if rec.DastStatus != record.DastStatusTimedOut { + t.Errorf("DastStatus = %q, want %q", rec.DastStatus, record.DastStatusTimedOut) + } + // A timed-out half is terminal but NOT readable. + if ctl.Readable(rec, record.HalfDast) { + t.Error("a timed-out DAST half must not be readable") + } + // The audit is consumable: the SAST findings are handed over with + // four hours of claim window left, which is the whole point. + rec = mustTransition(t, ctl, rec, ConsumeEvent()) + if rec.State != record.StateConsumed { + t.Fatalf("State = %q, want %q", rec.State, record.StateConsumed) + } + }) + + t.Run("non-binding DAST deadline: sast_sealed then expired, never stuck-collecting", func(t *testing.T) { + // deadlines.go documents this configuration and what it costs: + // "a never-terminating DAST run leaves the audit in + // record.StateSastSealed until clock 2 expires it, and the SAST + // findings are then lost to the claim window rather than handed + // over. Callers should surface a warning; the controller must not + // fail the scan over it." This is that path, asserted. + twelveHours := 12 * 60 * 60 + policy := DeadlinePolicy{DastEnabled: true, DastDeadlineSeconds: &twelveHours} + ctl, clk := newTestController(t, policy, WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-slow-nonbinding") + if rec.Deadlines.DastDeadlineBinds() { + t.Fatal("a 12h DAST deadline inside an 8h claim window must not bind") + } + + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if rec.State != record.StateSastSealed { + t.Fatalf("State = %q, want %q", rec.State, record.StateSastSealed) + } + + clk.set(8 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if rec.State != record.StateExpired { + t.Fatalf("State = %q at the claim deadline, want %q — NOT stuck", rec.State, record.StateExpired) + } + // CRITIQUE-03 M1: an expired audit is not readable even though its + // SAST half is cleanly sealed. One gate, both arms. + if rec.Sast.Status != record.HalfStatusSealed { + t.Fatalf("Sast.Status = %q; the seal itself survives expiry", rec.Sast.Status) + } + if _, err := ctl.Findings(rec, record.HalfSast); !errors.Is(err, record.ErrHalfNotSealed) { + t.Errorf("Findings(sast) on an expired audit = %v, want a read-gate refusal", err) + } + if ctl.Readable(rec, record.HalfSast) { + t.Error("Readable said true on an expired audit; the expiry arm was skipped") + } + }) +} + +// Ticking is idempotent and a tick on a settled audit is not an error: a +// daemon wakes on a schedule and must not log an error forever. +func TestTickIsIdempotent(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-ticks") + clk.set(9 * time.Hour) + + for i := 0; i < 4; i++ { + var err error + rec, err = ctl.Transition(rec, TickEvent()) + if err != nil { + t.Fatalf("tick %d: %v", i, err) + } + } + if rec.State != record.StateExpired { + t.Fatalf("State = %q, want %q", rec.State, record.StateExpired) + } +} + +// --------------------------------------------------------------------------- +// Version-bump watermarks +// --------------------------------------------------------------------------- + +func TestPublicationIsNotPerFinding(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 10, Interval: time.Hour}) + rec := mustBegin(t, ctl, "audit-watermark-count") + start := rec.Version + + for i := 0; i < 9; i++ { + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfDast, finding(fmt.Sprintf("r%d", i)))) + } + if rec.Version != start { + t.Fatalf("Version = %d after 9 of N=10 findings, want %d; per-finding publication is the named failure", rec.Version, start) + } + if rec.PendingDastFindings != 9 { + t.Errorf("PendingDastFindings = %d, want 9", rec.PendingDastFindings) + } + + before := rec + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfDast, finding("r9"))) + if !VersionBumped(before, rec) { + t.Fatalf("the 10th finding did not bump the version (was %d, is %d)", before.Version, rec.Version) + } + if rec.PendingDastFindings != 0 { + t.Errorf("PendingDastFindings = %d after a publication, want 0", rec.PendingDastFindings) + } +} + +func TestSastFindingsDoNotBumpUntilTheHalfSeals(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 2}) + rec := mustBegin(t, ctl, "audit-watermark-sast") + start := rec.Version + + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, findings(50)...)) + if rec.Version != start { + t.Fatalf("Version = %d, want %d: watermark (b) is scoped to DAST findings", rec.Version, start) + } + if rec.PendingDastFindings != 0 { + t.Errorf("PendingDastFindings = %d; SAST findings must not feed the DAST counter", rec.PendingDastFindings) + } + + before := rec + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + if !VersionBumped(before, rec) { + t.Error("watermark (a): the SAST seal must publish") + } +} + +func TestTimeWatermarkPublishesUnpublishedDastFindings(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1000, Interval: 15 * time.Minute}) + rec := mustBegin(t, ctl, "audit-watermark-time") + + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfDast, findings(3)...)) + before := rec + + // A tick before M elapses publishes nothing. + clk.set(10 * time.Minute) + rec = mustTransition(t, ctl, rec, TickEvent()) + if VersionBumped(before, rec) { + t.Fatalf("published at t+10m with M=15m (version %d -> %d)", before.Version, rec.Version) + } + + clk.set(15 * time.Minute) + rec = mustTransition(t, ctl, rec, TickEvent()) + if !VersionBumped(before, rec) { + t.Fatalf("no publication at t+15m with M=15m (version still %d)", rec.Version) + } + if !rec.PublishedAt.Equal(baseTime.Add(15 * time.Minute)) { + t.Errorf("PublishedAt = %s, want %s", rec.PublishedAt, baseTime.Add(15*time.Minute)) + } + + // With nothing unpublished, the time arm does not fire again. + before = rec + clk.set(2 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if VersionBumped(before, rec) { + t.Error("the time watermark fired with no unpublished DAST findings") + } +} + +func TestDastTerminalSealPublishes(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1000}) + rec := mustBegin(t, ctl, "audit-watermark-dast-terminal") + rec = mustTransition(t, ctl, rec, DastOutcomeEvent(bootedCleanOutcome())) + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfDast, findings(3)...)) + + before := rec + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfDast, record.HalfStatusSealed)) + if !VersionBumped(before, rec) { + t.Error("watermark (c): the DAST terminal state must publish") + } + if rec.PendingDastFindings != 0 { + t.Errorf("PendingDastFindings = %d after the terminal seal, want 0", rec.PendingDastFindings) + } +} + +func TestClockThreeSealPublishes(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-watermark-clock3") + before := rec + + clk.set(4 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if !VersionBumped(before, rec) { + t.Error("forcing the DAST half terminal on clock 3 must publish (watermark (c))") + } +} + +func TestConsumeDoesNotPublishAndIsReentrant(t *testing.T) { + ctl, _ := newTestController(t, sastOnlyPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-consume") + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + + before := rec + rec = mustTransition(t, ctl, rec, ConsumeEvent()) + if VersionBumped(before, rec) { + t.Error("consumption is not a publication watermark") + } + if rec.ConsumedAt == nil { + t.Fatal("ConsumedAt not stamped; audit_record.consumed_at would be NULL") + } + firstTake := *rec.ConsumedAt + + // Re-entrant: consuming again is accepted, does not move consumed_at, + // and leaves the sealed half readable. + again := mustTransition(t, ctl, rec, ConsumeEvent()) + if again.ConsumedAt == nil || !again.ConsumedAt.Equal(firstTake) { + t.Errorf("consumed_at moved on a second take: %v -> %v", firstTake, again.ConsumedAt) + } + if !ctl.Readable(again, record.HalfSast) { + t.Error("taking the record once must not shut the gate (S1: a RE-ENTRANT consumer)") + } +} + +func TestVersionIsMonotonic(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 2, Interval: time.Minute}) + rec := mustBegin(t, ctl, "audit-monotonic") + last := rec.Version + + steps := []Event{ + FindingsEvent(record.HalfDast, findings(2)...), + DastOutcomeEvent(bootedCleanOutcome()), + FindingsEvent(record.HalfSast, findings(3)...), + SealHalfEvent(record.HalfSast, record.HalfStatusSealed), + FindingsEvent(record.HalfDast, findings(1)...), + TickEvent(), + SealHalfEvent(record.HalfDast, record.HalfStatusSealed), + ConsumeEvent(), + } + for i, ev := range steps { + clk.set(time.Duration(i+1) * 10 * time.Minute) + rec = mustTransition(t, ctl, rec, ev) + if rec.Version < last { + t.Fatalf("step %d (%s): version went backwards, %d -> %d", i, ev.Kind, last, rec.Version) + } + if rec.Version < 1 { + t.Fatalf("step %d: version %d violates ck_audit_record_audit_version_positive", i, rec.Version) + } + last = rec.Version + } +} + +// --------------------------------------------------------------------------- +// The single durable write +// --------------------------------------------------------------------------- + +func TestDurableWriteDueFiresExactlyOncePerAudit(t *testing.T) { + tests := []struct { + name string + policy DeadlinePolicy + run func(t *testing.T, ctl *Controller, clk *testClock, rec AuditRecord) []AuditRecord + }{ + { + name: "seal then consume", + policy: dastPolicy(), + run: func(t *testing.T, ctl *Controller, clk *testClock, rec AuditRecord) []AuditRecord { + out := []AuditRecord{rec} + for _, ev := range []Event{ + FindingsEvent(record.HalfSast, finding("a")), + DastOutcomeEvent(bootedCleanOutcome()), + SealHalfEvent(record.HalfSast, record.HalfStatusSealed), + SealHalfEvent(record.HalfDast, record.HalfStatusSealed), + ConsumeEvent(), + } { + rec = mustTransition(t, ctl, rec, ev) + out = append(out, rec) + } + return out + }, + }, + { + name: "expire with only the SAST half sealed", + policy: sastOnlyPolicy(), + run: func(t *testing.T, ctl *Controller, clk *testClock, rec AuditRecord) []AuditRecord { + out := []AuditRecord{rec} + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, finding("a"))) + out = append(out, rec) + clk.set(8 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + out = append(out, rec) + rec = mustTransition(t, ctl, rec, TickEvent()) + return append(out, rec) + }, + }, + { + name: "seal, then expire without ever being consumed", + policy: dastPolicy(), + run: func(t *testing.T, ctl *Controller, clk *testClock, rec AuditRecord) []AuditRecord { + out := []AuditRecord{rec} + for _, ev := range []Event{ + DastOutcomeEvent(bootedCleanOutcome()), + SealHalfEvent(record.HalfDast, record.HalfStatusSealed), + SealHalfEvent(record.HalfSast, record.HalfStatusSealed), + } { + rec = mustTransition(t, ctl, rec, ev) + out = append(out, rec) + } + clk.set(8 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + return append(out, rec) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl, clk := newTestController(t, tt.policy, WatermarkPolicy{}) + seq := tt.run(t, ctl, clk, mustBegin(t, ctl, "audit-write-"+tt.name)) + + writes := 0 + for i := 1; i < len(seq); i++ { + if DurableWriteDue(seq[i-1], seq[i]) { + writes++ + } + } + if writes != 1 { + t.Fatalf("DurableWriteDue fired %d times across %d transitions; want exactly 1", writes, len(seq)-1) + } + }) + } +} + +// --------------------------------------------------------------------------- +// The read gate — called, never re-derived +// --------------------------------------------------------------------------- + +func TestFindingsAreGatedInEveryUnreadableShape(t *testing.T) { + tests := []struct { + name string + status record.HalfStatus + }{ + {"running", record.HalfStatusRunning}, + {"failed", record.HalfStatusFailed}, + {"timed_out", record.HalfStatusTimedOut}, + {"skipped", record.HalfStatusSkipped}, + } + for _, tt := range tests { + t.Run("sast half is "+tt.name, func(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-gate-"+tt.name) + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, findings(2)...)) + if tt.status != record.HalfStatusRunning { + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, tt.status)) + } + if rec.Sast.Status != tt.status { + t.Fatalf("Sast.Status = %q, want %q", rec.Sast.Status, tt.status) + } + got, err := ctl.Findings(rec, record.HalfSast) + if !errors.Is(err, record.ErrHalfNotSealed) { + t.Fatalf("Findings error = %v, want record.ErrHalfNotSealed", err) + } + if got != nil { + t.Errorf("a refused read returned %d findings; it must return nothing", len(got)) + } + if ctl.Readable(rec, record.HalfSast) { + t.Error("Readable disagreed with Findings; there is meant to be ONE gate") + } + // The count is deliberately ungated — it is metadata, not + // results — so it still answers. + if rec.Sast.FindingCount() != 2 { + t.Errorf("FindingCount = %d, want 2", rec.Sast.FindingCount()) + } + }) + } +} + +// Findings and Readable must never disagree, in any state, for either half. +func TestReadableAgreesWithFindingsEverywhere(t *testing.T) { + for _, s := range record.StateValues() { + for _, half := range record.HalfValues() { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec, err := driveToState(t, ctl, clk, s) + if err != nil { + t.Fatalf("driving to %q: %v", s, err) + } + _, readErr := ctl.Findings(rec, half) + if got, want := readErr == nil, ctl.Readable(rec, half); got != want { + t.Errorf("state %q half %q: Findings ok=%v but Readable=%v", s, half, got, want) + } + } + } +} + +func TestFindingsOnAnUnknownHalf(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-unknown-half") + _, err := ctl.Findings(rec, record.Half("host")) + var ee *record.EnumError + if !errors.As(err, &ee) || ee.Field != "anvil/half" { + t.Fatalf("Findings(host) error = %v, want a *record.EnumError on anvil/half", err) + } + if ctl.Readable(rec, record.Half("host")) { + t.Error("an unknown half must never be readable") + } +} + +// Findings returns a COPY: mutating the returned slice must not reach into the +// record a consumer will read again. +func TestFindingsReturnsACopy(t *testing.T) { + ctl, _ := newTestController(t, sastOnlyPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-copy") + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfSast, findings(2)...)) + rec = mustTransition(t, ctl, rec, SealHalfEvent(record.HalfSast, record.HalfStatusSealed)) + + got, err := ctl.Findings(rec, record.HalfSast) + if err != nil { + t.Fatalf("Findings: %v", err) + } + got[0].RuleID = "tampered" + + again, err := ctl.Findings(rec, record.HalfSast) + if err != nil { + t.Fatalf("Findings (second read): %v", err) + } + if again[0].RuleID == "tampered" { + t.Error("Findings handed out the backing array; a consumer can rewrite a sealed half") + } +} + +// --------------------------------------------------------------------------- +// Value semantics +// --------------------------------------------------------------------------- + +func TestTransitionDoesNotMutateItsInput(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1}) + before := mustBegin(t, ctl, "audit-immutable") + before = mustTransition(t, ctl, before, FindingsEvent(record.HalfSast, finding("a"))) + + snapshot := before + after := mustTransition(t, ctl, before, FindingsEvent(record.HalfSast, finding("b"))) + + if before.Version != snapshot.Version || before.State != snapshot.State { + t.Errorf("Transition mutated the input's scalars: %+v vs %+v", before, snapshot) + } + if before.Sast.FindingCount() != 1 { + t.Errorf("input Sast.FindingCount = %d, want 1; the input's findings were appended to", before.Sast.FindingCount()) + } + if after.Sast.FindingCount() != 2 { + t.Errorf("output Sast.FindingCount = %d, want 2", after.Sast.FindingCount()) + } +} + +func TestCorrelationIsCopiedNotAliased(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-correlation") + + clusters := []record.Correlation{{ClusterID: "c1", Role: record.HalfSast}} + rec = mustTransition(t, ctl, rec, CorrelateEvent(clusters...)) + clusters[0].ClusterID = "tampered" + + if len(rec.Correlation) != 1 || rec.Correlation[0].ClusterID != "c1" { + t.Errorf("Correlation aliased the caller's slice: %+v", rec.Correlation) + } +} + +// --------------------------------------------------------------------------- +// acceptsWrites must not drift from the Sealer's own guard +// --------------------------------------------------------------------------- + +func TestAcceptsWritesAgreesWithTheSealer(t *testing.T) { + for _, s := range record.StateValues() { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec, err := driveToState(t, ctl, clk, s) + if err != nil { + t.Fatalf("driving to %q: %v", s, err) + } + if rec.State != s { + t.Fatalf("expected state %q, got %q", s, rec.State) + } + + // The Sealer's own guard: SealHalf refuses a consumed or expired + // audit with record.ErrAuditTerminal before it looks at the half. + sealErr := ctl.Sealer().SealHalf(rec.AuditID, record.HalfSast, record.HalfStatusSealed) + sealerRefused := errors.Is(sealErr, record.ErrAuditTerminal) + + if got := acceptsWrites(s); got == sealerRefused { + t.Errorf("state %q: acceptsWrites = %v but the Sealer's ErrAuditTerminal guard = %v; the mirror has drifted", + s, got, sealerRefused) + } + } +} + +func TestSettledIsTotalOverTheStateEnum(t *testing.T) { + want := map[record.State]bool{ + record.StateCollecting: false, + record.StateSastSealed: false, + record.StateDastSealed: false, + record.StateBothSealed: true, + record.StateConsumed: true, + record.StateExpired: true, + } + if len(want) != len(record.StateValues()) { + t.Fatalf("this table covers %d states but the frozen enum has %d; R.1 changed under us", + len(want), len(record.StateValues())) + } + for _, s := range record.StateValues() { + if got := settled(s); got != want[s] { + t.Errorf("settled(%q) = %v, want %v", s, got, want[s]) + } + } +} + +// --------------------------------------------------------------------------- +// WatermarkPolicy +// --------------------------------------------------------------------------- + +func TestWatermarkPolicyResolve(t *testing.T) { + fourHours := 4 * time.Hour + + t.Run("zero resolves to the derived defaults", func(t *testing.T) { + got, err := WatermarkPolicy{}.Resolve(fourHours) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.DastFindings != DefaultWatermarkDastFindings { + t.Errorf("DastFindings = %d, want %d", got.DastFindings, DefaultWatermarkDastFindings) + } + if got.Interval != 15*time.Minute { + t.Errorf("Interval = %s, want 15m (4h / %d)", got.Interval, WatermarkIntervalDivisor) + } + }) + + t.Run("idempotent", func(t *testing.T) { + once, err := WatermarkPolicy{}.Resolve(fourHours) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + twice, err := once.Resolve(fourHours) + if err != nil { + t.Fatalf("Resolve twice: %v", err) + } + if once != twice { + t.Errorf("Resolve is not idempotent: %+v vs %+v", once, twice) + } + }) + + t.Run("M scales with the budget rather than being written down", func(t *testing.T) { + got, err := WatermarkPolicy{}.Resolve(32 * time.Minute) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.Interval != 2*time.Minute { + t.Errorf("Interval = %s, want 2m for a 32m budget", got.Interval) + } + }) + + t.Run("a tiny budget still yields a positive M", func(t *testing.T) { + if got := DefaultWatermarkInterval(time.Nanosecond); got != time.Second { + t.Errorf("DefaultWatermarkInterval(1ns) = %s, want 1s", got) + } + if got := DefaultWatermarkInterval(0); got != time.Second { + t.Errorf("DefaultWatermarkInterval(0) = %s, want 1s", got) + } + }) + + t.Run("negatives are rejected", func(t *testing.T) { + if _, err := (WatermarkPolicy{DastFindings: -1}).Resolve(fourHours); !errors.Is(err, ErrInvalidWatermarkPolicy) { + t.Errorf("negative N error = %v, want ErrInvalidWatermarkPolicy", err) + } + if _, err := (WatermarkPolicy{Interval: -time.Second}).Resolve(fourHours); !errors.Is(err, ErrInvalidWatermarkPolicy) { + t.Errorf("negative M error = %v, want ErrInvalidWatermarkPolicy", err) + } + }) + + t.Run("NewController rejects an invalid watermark policy", func(t *testing.T) { + if _, err := NewController(dastPolicy(), WatermarkPolicy{DastFindings: -5}); !errors.Is(err, ErrInvalidWatermarkPolicy) { + t.Errorf("NewController error = %v, want ErrInvalidWatermarkPolicy", err) + } + }) + + t.Run("NewController rejects an invalid deadline policy", func(t *testing.T) { + if _, err := NewController(DeadlinePolicy{ClaimTimeoutSeconds: -1}, WatermarkPolicy{}); !errors.Is(err, ErrInvalidDeadlinePolicy) { + t.Errorf("NewController error = %v, want ErrInvalidDeadlinePolicy", err) + } + }) +} + +// With no DAST half there is no DAST deadline, so M is derived from half the +// claim window — the same quantity DefaultDastDeadlineSeconds would produce. +// M must not jump when an operator installs `anvil-dast` and takes the default. +func TestWatermarkIntervalIsStableAcrossTheDastInstall(t *testing.T) { + withDast, err := NewController(dastPolicy(), WatermarkPolicy{}) + if err != nil { + t.Fatalf("NewController(dast): %v", err) + } + withoutDast, err := NewController(sastOnlyPolicy(), WatermarkPolicy{}) + if err != nil { + t.Fatalf("NewController(sast-only): %v", err) + } + if a, b := withDast.Watermarks().Interval, withoutDast.Watermarks().Interval; a != b { + t.Errorf("M jumped across the anvil-dast install: %s vs %s", a, b) + } + if got := withDast.Watermarks().Interval; got != 15*time.Minute { + t.Errorf("Interval = %s, want 15m at the shipped defaults", got) + } +} + +// --------------------------------------------------------------------------- +// Scheduling +// --------------------------------------------------------------------------- + +func TestNextWakeWidensDeadlinesWithTheTimeWatermark(t *testing.T) { + ctl, clk := newTestController(t, dastPolicy(), WatermarkPolicy{DastFindings: 1000, Interval: 15 * time.Minute}) + rec := mustBegin(t, ctl, "audit-nextwake") + + // With nothing pending, the next wake is clock 3 at t+4h. + at, ok := ctl.NextWake(rec) + if !ok || !at.Equal(baseTime.Add(4*time.Hour)) { + t.Fatalf("NextWake = (%s, %v), want (%s, true)", at, ok, baseTime.Add(4*time.Hour)) + } + + // One unpublished DAST finding pulls the wake forward to the M boundary, + // which Deadlines alone cannot know about. + rec = mustTransition(t, ctl, rec, FindingsEvent(record.HalfDast, finding("a"))) + at, ok = ctl.NextWake(rec) + if !ok || !at.Equal(baseTime.Add(15*time.Minute)) { + t.Fatalf("NextWake with pending findings = (%s, %v), want (%s, true)", at, ok, baseTime.Add(15*time.Minute)) + } + + // Past both deadlines with nothing pending, there is nothing to wait for. + clk.set(9 * time.Hour) + rec = mustTransition(t, ctl, rec, TickEvent()) + if at, ok := ctl.NextWake(rec); ok { + t.Errorf("NextWake = (%s, true) past both deadlines, want ok=false", at) + } +} + +// --------------------------------------------------------------------------- +// Vocabulary +// --------------------------------------------------------------------------- + +// The event vocabulary must not collide with any of R.1's frozen enums. A +// collision is how "two areas meaning different things by the same field name" +// starts, and this package owns no vocabulary at all. +func TestEventKindsDoNotCollideWithFrozenEnums(t *testing.T) { + frozen := map[string]string{} + for _, v := range record.StateValues() { + frozen[string(v)] = "anvil/state" + } + for _, v := range record.HalfStatusValues() { + frozen[string(v)] = "anvil/status" + } + for _, v := range record.DastStatusValues() { + frozen[string(v)] = "anvil/dastStatus" + } + for _, k := range EventKindValues() { + if field, clash := frozen[string(k)]; clash { + t.Errorf("EventKind %q collides with a %s literal", k, field) + } + if !k.Valid() { + t.Errorf("EventKind %q is not reported valid by its own predicate", k) + } + } + if EventKind("").Valid() { + t.Error("the empty EventKind must not be valid") + } +} + +func TestTransitionErrorNamesTheCaller(t *testing.T) { + ctl, _ := newTestController(t, dastPolicy(), WatermarkPolicy{}) + rec := mustBegin(t, ctl, "audit-error-message") + + _, err := ctl.Transition(rec, FindingsEvent(record.HalfSast)) + var te *TransitionError + if !errors.As(err, &te) { + t.Fatalf("error %v is not a *TransitionError", err) + } + if te.AuditID != "audit-error-message" || te.Kind != EventKindFindings || + te.Half != record.HalfSast || te.State != record.StateCollecting { + t.Errorf("TransitionError does not identify the caller: %+v", te) + } + if te.Error() == "" { + t.Error("empty error message") + } +} diff --git a/schemas/policy.schema.json b/schemas/policy.schema.json new file mode 100644 index 0000000..a46c377 --- /dev/null +++ b/schemas/policy.schema.json @@ -0,0 +1,227 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://anvil.invalid/schemas/policy.schema.json", + "title": "Anvil trigger policy (.anvil/policy.yml) v1", + "description": "THE definition of Anvil's trigger-policy file. Ground truth: plan/70-orchestration-ci.md step O.5 and its 'Trigger Policy Schema' section; shape from research/09-orchestration-and-github-actions.md Recommendation 2 (Renovate `packageRules` convention -- match keys prefixed `match*`, applied in ARRAY ORDER, later rules override earlier). plan/00-SPINE.md S1 makes 'no hard-coded triggers' a hard constraint: every matchable value -- event names, ref globs, path globs, semver-bump kinds, schedule cadences, timeouts, severity gates -- is DATA parsed from this file. A literal such as \"push\" or \"major\" used as a match condition anywhere in Anvil outside the parser is a defect, not a shortcut. Renovate is AGPL-3.0: this file copies the CONVENTION only, no Renovate code or types are vendored (research/09 Risks 9).", + + "x-anvil-owner": "plan/70-orchestration-ci.md step O.5. This file is the SINGLE schema for .anvil/policy.yml. plan/IMPLEMENTATION-PLAN.md section 6 closed ten defects whose shared shape was 'two areas each defined the vocabulary from their own side'; a second policy schema would be the eleventh. Consumers -- internal/policy/engine.go (O.6), internal/policy/semver.go (O.7), the GitHub Action (O.8), and area D's DAST overrides -- validate against THIS file and extend it here. They do not fork it.", + "x-anvil-consumers": [ + "internal/policy/locate.go (O.5) -- finds the file; see x-anvil-searchOrder", + "internal/policy/engine.go (O.6) -- Renovate-shaped match/apply evaluation, generic over whatever scanRules this file contains", + "internal/policy/semver.go (O.7) -- computes the value matchSemverBump is matched against; GitHub's payload has no previous tag, so it is derived, never read from the event", + "action/action.yml (O.8) -- evaluates policy on the runner before waking the daemon (thin Action, fat daemon)", + "area D (DAST) -- extends #/$defs/dastOverrides IN THIS FILE" + ], + "x-anvil-searchOrder": [ + ".anvil/policy.yml", + ".anvil/policy.yaml", + ".anvil/policy.toml", + "anvil.toml", + ".github/anvil.yml" + ], + "x-anvil-searchOrderNote": "Stop at first match, mirroring Renovate (research/09 Recommendation 2). The list is implemented ONCE, in internal/policy/locate.go, and exported as policy.SearchOrder() so the Action and any other consumer read the same order rather than re-listing it. The .toml entries are locations only -- this schema describes the document shape; a TOML encoding of the same shape validates against this schema after decoding.", + "x-anvil-strictness": "Every object here sets additionalProperties:false ON PURPOSE. In a design whose entire premise is 'the policy is data', a typo such as `matchEvent:` for `matchEvents:` would otherwise parse cleanly and silently match nothing -- a scan that never fires and never errors. Rejecting unknown keys converts that class of silent failure into a load-time error.", + "x-anvil-yamlHeader": "Publish this schema at a stable URL and honour a `# yaml-language-server: $schema=` header the way Renovate publishes renovate-schema.json (research/09 Recommendation 2). The daemon also serves it locally so schema resolution never requires live internet access (plan/70-orchestration-ci.md 'Trigger Policy Schema').", + + "x-anvil-aggregateBounds": "THIS FILE IS BOUNDED IN AGGREGATE, NOT ONLY PER ITEM. #/$defs/glob caps the cost of ONE pattern (CRITIQUE O.4 finding O4-M4: 8.51 seconds for a single crafted pattern). That says nothing about HOW MANY patterns there are, and the same denial of service is reachable by MULTIPLICATION -- ten thousand cheap rules are the same outage as one expensive one, and this file is read FROM THE REPOSITORY UNDER SCAN in both cases. So `scanRules` carries maxItems (internal/policy.MaxScanRules) and every list-valued key carries maxItems (internal/policy.MaxListItems). TWO FURTHER BOUNDS EXIST THAT JSON SCHEMA CANNOT EXPRESS, because they are properties of an EVALUATION rather than of this document: internal/policy.MaxChangedPaths caps how many changed paths one evaluation will consider, and internal/policy.MaxEvaluationMatchOps caps the worst-case number of pattern matches the whole evaluation may perform -- which is the bound that actually closes the multiplication, since rules x patterns x paths at the three caps above is 134 million matches. The engine enforces all four, because a policy can reach it without ever passing through this schema. Exceeding any of them is a REFUSAL naming the bound and its limit (policy.ErrPolicyTooLarge), never a truncation: an operator who believes a rule is in force when it was silently dropped is worse off than one whose policy was rejected.", + "x-anvil-aggregateBoundsDrift": "TestPolicySchemaAggregateBoundsMatchTheEngineCaps (internal/policy/schema_test.go) fails if any maxItems here drifts from the Go constant it mirrors.", + + "type": "object", + "required": ["version"], + "additionalProperties": false, + "properties": { + "version": { + "description": "Policy-file schema version. NOT an Anvil release version. Only 1 exists; a future 2 must add a value here rather than reinterpret 1, so an old daemon fails loudly on a new file instead of misreading it.", + "type": "integer", + "const": 1 + }, + "defaults": { + "description": "Baseline settings, merged UNDER every matched rule. DAST is opt-in and stays opt-in because this object's `detectors` omits it -- that default lives in the user's data, not in Anvil's code. A policy with no `defaults` key inherits nothing implicitly: the engine's own fallback is the empty settings object, and a rule that sets nothing resolves to nothing.", + "$ref": "#/$defs/settings" + }, + "scanRules": { + "description": "Match/apply rules in Renovate `packageRules` order. SEMANTICS, and the engine (O.6) must implement exactly this: evaluate EVERY rule in array order; a rule whose match* keys all match contributes its settings; later matching rules override earlier ones FIELD BY FIELD, they do not replace the whole resolved rule and evaluation does NOT short-circuit on first match. Precedence is therefore: last matching rule's field > earlier matching rule's field > `defaults` field. Rule ORDER is significant; put broad rules first and narrow overrides last. An empty or absent array means only `defaults` applies. `name` should be unique across the array -- this schema cannot express that constraint, so the loader enforces it. BOUNDED ON PURPOSE: maxItems is internal/policy.MaxScanRules. Because evaluation does not short-circuit, EVERY rule costs work on EVERY event, so the rule count is a multiplier on the whole evaluation -- see x-anvil-aggregateBounds. 256 is roughly sixty times the owner's own fixture and beyond any hand-maintained trigger policy; a file past it is refused whole, never trimmed.", + "x-anvil-engineCap": "internal/policy.MaxScanRules", + "type": "array", + "maxItems": 256, + "items": { "$ref": "#/$defs/scanRule" } + } + }, + + "$defs": { + "duration": { + "description": "A Go time.ParseDuration string, e.g. \"20m\", \"90m\", \"1h30m\". Anvil never compiles a default duration in as a match condition; every timeout here is parsed from this file.", + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?(ns|us|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|ms|s|m|h))*$" + }, + + "glob": { + "description": "A glob pattern, matched by the engine against a ref or path supplied by the trigger context. The pattern itself is never a Go literal: it is whatever the user wrote here. BOUNDED ON PURPOSE: this file is read FROM THE REPOSITORY UNDER SCAN, so on the public-repo path a pattern is untrusted input reaching a matcher. maxLength is the same 1024-byte cap internal/policy enforces as policy.MaxGlobPatternBytes, and the engine also caps a pattern at 64 `/`-separated segments (policy.MaxGlobPatternSegments); a pattern past either bound is refused with policy.ErrPatternTooComplex at load time rather than matched. See CRITIQUE O.4 finding O4-M4, which measured the previous unbounded matcher at 8.5 seconds for one pattern against one path.", + "x-anvil-engineCap": "internal/policy.MaxGlobPatternBytes (maxLength here) and internal/policy.MaxGlobPatternSegments (segments, which JSON Schema cannot express); TestPolicySchemaGlobBoundMatchesTheEngineCap fails if they drift apart", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + + "globList": { + "description": "A non-empty, duplicate-free list of glob patterns. An ABSENT list and an EMPTY list are deliberately different: absent means 'this dimension is not constrained by this rule' (the rule still matches); empty would mean 'constrained to nothing' (the rule could never match), which is always an authoring mistake, so minItems forbids it. BOUNDED AT THE OTHER END TOO: maxItems is internal/policy.MaxListItems. #/$defs/glob caps the cost of one pattern; this caps how many of them a single key may carry, which is the other half of the same denial of service -- see x-anvil-aggregateBounds. 64 is the same number as the engine's per-pattern segment cap and for the same reason: wider than a human writes. CodeQL's path filters, which matchPaths borrows its naming from, run to a dozen or two.", + "x-anvil-engineCap": "internal/policy.MaxListItems", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { "$ref": "#/$defs/glob" } + }, + + "tokenList": { + "description": "A non-empty, duplicate-free list of opaque lowercase tokens. See globList on why empty is rejected, and on why maxItems is internal/policy.MaxListItems -- the loader's duplicate check is a linear scan per item and therefore QUADRATIC in the list length, so an unbounded token list is a denial of service in the decoder before a single glob is compiled.", + "x-anvil-engineCap": "internal/policy.MaxListItems", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + + "detectorList": { + "description": "Which detector tiers this rule runs. The legal token set is area 40's DetectorKind vocabulary (sast | dast | sca | host) -- see internal/record/contract.go and schemas/anvil-record-v1.schema.json#/$defs/detectorKind. It is deliberately NOT re-enumerated here: copying another area's enum into this file is exactly the defect class plan/IMPLEMENTATION-PLAN.md section 6 closed ten instances of. The loader validates each token against internal/record.DetectorKind, which is the one definition, and rejects unknown tokens at load time.", + "x-anvil-enumSource": "internal/record.DetectorKind -- schemas/anvil-record-v1.schema.json#/$defs/detectorKind", + "$ref": "#/$defs/tokenList" + }, + + "depth": { + "description": "How much of the tree a scan covers. `delta` is the changed-surface pass the Action may run inline on the runner; `full` is the whole-tree pass that is dispatched to the daemon (thin Action, fat daemon -- research/09 Recommendation 3). This enum IS owned here, by O.5, because no other area declares it; O.6 and O.8 consume these two tokens rather than declaring a third.", + "x-anvil-enumOwner": "schemas/policy.schema.json (O.5)", + "type": "string", + "enum": ["delta", "full"] + }, + + "semverBump": { + "description": "Kinds of version bump a tag may represent. Anvil COMPUTES the bump (O.7, `git describe --tags --abbrev=0 ^`) because the GitHub event payload carries no previous tag -- which is why the Action must check out with fetch-depth: 0 and fetch-tags: true, an operational footgun worth stating loudly (research/09 Recommendation 2). This enum is owned here, by O.5, and consumed by O.7; O.7 does not declare a second one.", + "x-anvil-enumOwner": "schemas/policy.schema.json (O.5); computed by internal/policy/semver.go (O.7)", + "type": "string", + "enum": ["major", "minor", "patch", "prerelease"] + }, + + "settings": { + "description": "The overridable settings block. The SAME field definitions are reachable from `defaults` and from each scanRule, by design: one definition per field, merged field-by-field with later-overrides-earlier precedence.", + "type": "object", + "additionalProperties": false, + "properties": { + "detectors": { "$ref": "#/$defs/detectorList" }, + "depth": { "$ref": "#/$defs/depth" }, + "timeout": { + "description": "Wall-clock budget for the scan this rule resolves to. Distinct from the record's retention deadline (O.1 DeadlinePolicy): a timeout bounds the work, deadline_at bounds how long the record lives.", + "$ref": "#/$defs/duration" + }, + "failOn": { + "description": "Severity at or above which the scan reports failure to the caller -- Trivy's `severity` + `exit-code` pair, borrowed for familiarity (research/09 Recommendation 2). NOT enumerated here on purpose: Anvil's severity vocabulary is area 40's (SARIF result.level plus the anvil/* extension), and duplicating it into this file would create a second definition. The loader maps this token through area 40's vocabulary and rejects anything unmapped. OPEN CROSS-AREA ITEM, flagged by O.5: research/09's example writes `failOn: high`, while SARIF result.level is none|note|warning|error -- the mapping from the policy token to the record level needs one named owner before O.6 ships.", + "x-anvil-enumSource": "internal/record (SARIF result.level and the anvil/* severity extension)", + "type": "string", + "minLength": 1 + }, + "publish": { + "description": "Result sinks to publish to, e.g. sarif. Not enumerated: the sink set is a registry other areas add to (SARIF file, GitHub code scanning, DefectDojo), and freezing it here would fork it. The loader validates each token against the registered sinks.", + "$ref": "#/$defs/tokenList" + }, + "dast": { "$ref": "#/$defs/dastOverrides" } + } + }, + + "dastOverrides": { + "description": "DAST-half settings. Only meaningful when this rule's resolved `detectors` includes the dast token; the engine warns rather than silently ignoring it otherwise. AREA D EXTENDS THIS OBJECT IN PLACE. additionalProperties is false, so a new DAST knob must be added here -- that is the point: a second dast policy schema is the fork this file exists to prevent.", + "x-anvil-extendedBy": "area D (DAST) -- add fields to this $def, do not fork", + "type": "object", + "additionalProperties": false, + "properties": { + "profile": { + "description": "Named DAST profile, e.g. authenticated. Opaque to the engine and to this schema -- profile names are the user's and area D's data, never Go literals here.", + "type": "string", + "minLength": 1 + }, + "maxDuration": { + "description": "Upper bound on the DAST half's own runtime. Independent of `timeout`, and bounded in turn by the controller's dast_deadline (O.1): a ZAP full scan that outruns the retention window is cut off there and the half seals with the frozen anvil/dastStatus token for a timeout, whatever this file says.", + "$ref": "#/$defs/duration" + } + } + }, + + "schedule": { + "description": "Cadence for a rule matching a scheduled event. The daemon-side systemd clock is authoritative; GitHub's `schedule:` is a mirror only, because GitHub auto-disables scheduled workflows after 60 days of repository inactivity (plan/70-orchestration-ci.md 'Trigger Policy Schema').", + "type": "object", + "additionalProperties": false, + "properties": { + "onCalendar": { + "description": "A systemd OnCalendar= expression, e.g. \"*-*-* 03:17:00\", PASSED THROUGH VERBATIM to the timer unit. Anvil neither parses nor normalises it, and no cadence is compiled in: this string is the whole cadence definition.", + "type": "string", + "minLength": 1 + }, + "persistent": { + "description": "systemd Persistent=. True means a run missed while the host was down fires on the next boot -- the property that makes the daemon clock, not GitHub's, authoritative for the nightly regression pass.", + "type": "boolean" + }, + "randomizedDelay": { + "description": "systemd RandomizedDelaySec=, expressed as a duration. Spreads load when many repositories share one calendar expression.", + "$ref": "#/$defs/duration" + } + } + }, + + "scanRule": { + "description": "One match/apply rule. The match* keys are ANDed: every match* key present must match for the rule to contribute. A rule with NO match* keys matches everything -- legal, and the idiomatic way to write a broad baseline rule that later rules narrow. Because later matching rules override earlier ones field by field (see scanRules), a rule need only carry the fields it changes.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "description": "Stable identifier for this rule. Appears in diagnostics and in the record's policy reference, so renaming a rule changes what an audit record points at. Should be unique within scanRules; the loader enforces uniqueness, which JSON Schema cannot express here.", + "type": "string", + "minLength": 1 + }, + + "matchEvents": { + "description": "Trigger event names this rule applies to, e.g. push, release, schedule, workflow_dispatch. DELIBERATELY NOT ENUMERATED. Event names are the platform's vocabulary and the owner's hard constraint is that none of them is compiled into Anvil; the engine compares the trigger context's event string against whatever tokens appear here. Note the research finding behind the usual `[push, release]` pairing: pushing more than three tags at once drops the plain push events, so a tag rule that lists only push can silently miss releases.", + "$ref": "#/$defs/tokenList" + }, + "matchRefs": { + "description": "Git ref globs this rule applies to, e.g. refs/heads/** or refs/tags/v*. Matched against the trigger context's fully-qualified ref.", + "$ref": "#/$defs/globList" + }, + "matchRefsIgnore": { + "description": "Git ref globs that EXCLUDE the rule. Applied after matchRefs: a ref matching any pattern here does not match the rule even if matchRefs accepted it.", + "$ref": "#/$defs/globList" + }, + "matchPaths": { + "description": "Changed-path globs this rule applies to, naming borrowed from the CodeQL config file (research/09 Recommendation 2). The rule matches if ANY changed path in the trigger context matches any pattern here.", + "$ref": "#/$defs/globList" + }, + "matchPathsIgnore": { + "description": "Changed-path globs that EXCLUDE the rule, e.g. docs/** or **/*.md. Applied after matchPaths, and applied PER PATH: a change set is excluded only when every changed path matches an ignore pattern, so a commit touching both docs/ and source still triggers the rule.", + "$ref": "#/$defs/globList" + }, + "matchSemverBump": { + "description": "Kinds of version bump this rule applies to. Only meaningful for ref globs that select tags; for a non-tag ref the trigger context carries no bump and a rule listing this key does not match. The bump is computed by Anvil (O.7), never read from the event payload. Together with matchEvents and matchRefs this is the minimum triple that expresses the owner's explicit requirement -- SAST on every push, SAST+DAST on tagged releases -- with no event name or bump kind compiled into Anvil. maxItems is internal/policy.MaxListItems, applied here for uniformity rather than for cost: uniqueItems over a four-value enum already bounds this list at four. The engine applies the SAME cap to every list-valued key, and a key exempted from a uniform rule is the key a later edit forgets.", + "x-anvil-engineCap": "internal/policy.MaxListItems", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { "$ref": "#/$defs/semverBump" } + }, + + "schedule": { "$ref": "#/$defs/schedule" }, + + "detectors": { "$ref": "#/$defs/detectorList" }, + "depth": { "$ref": "#/$defs/depth" }, + "timeout": { "$ref": "#/$defs/duration" }, + "failOn": { + "description": "Overrides defaults.failOn for this rule. See #/$defs/settings for the full field description.", + "type": "string", + "minLength": 1 + }, + "publish": { "$ref": "#/$defs/tokenList" }, + "dast": { "$ref": "#/$defs/dastOverrides" } + } + } + } +}