Pular para conteúdo

AUR-361 — bootstrap action lock (.board/bootstrap/locks/actions.yml)

Documento histórico do board. Pode descrever componentes removidos; para uso atual, consulte o guia do produto.

Why this is a derivation and not a declaration

Round 1 of this card shipped a lock whose Section 1 (whole-file digests) was the only thing the sealed gate consulted, while Section 3 (the entry_NNN_* records that claimed "every uses: site is registered and pinned") was hand-written and read only to pick a cosmetic label. An adversarial reviewer reproduced the consequence end-to-end in the real container: append a job with uses: some-untrusted-org/some-action@main to a real workflow, recompute only that file's Section-1 digest — exactly what a legitimate author updating the lock does — leave Section 3 untouched, and the sealed accept exits 0 with "result":"pass".

The defect was not "the scanner missed a spelling of uses:". The defect was structural: a field that nobody derives is a field an attacker can leave standing. Section 3 was an assertion, and an assertion about the material under test is worth nothing when the material is what the attacker edits.

The redesign removes the class rather than the instance:

  1. The whole lock is generated. tests/acceptance/AUR-361.sh --generate is a pure function of the bytes under action.yml and .github/workflows. Section 1 and Section 3 come out of the same single pass over the same bytes. There is no hand-written field left in the file.
  2. The gate regenerates and compares byte for byte. The acceptance program derives the complete lock into a temporary file and runs cmp against the committed lock. One byte of divergence is exit 1. There is no field the attacker can leave stale, because there is no field they can set: a new uses: site changes the derived entry list, therefore the derived bytes, therefore the comparison.
  3. The rules run on derived entries. Trusted source, ref type, and pinning are evaluated over the entries the derivation just produced, never over entries a human wrote. This is what stops --generate from becoming the new bypass: regenerating the lock after adding some-untrusted-org/some-action@main makes the byte comparison agree and then the derived-entry policy refuses the run anyway.
  4. Emptiness is failure. Zero input files, zero derived uses: sites, a missing action.yml, a missing .github/workflows, no uses: inside action.yml, or no uses: under .github/workflows each exit 1. An AUR-361 with no uses: site is a broken board, not a clean one.

Round 3: killing "the check that never runs"

Round 2's derivation was honest but its policy still classified by branch, and two branches skipped a check. Both were reproduced end-to-end inside the sealed container with exit_code: 0 / "result":"pass":

  • B1 — the docker branch had no owner. owner_is_trusted was called only when entry_kind == remote. For entry_kind == docker the owner was the hard-coded literal string docker, compared against nothing. A uses: docker://evil-registry.example.com/malware@sha256:<64 hex> was recorded in the generated lock and accepted.
  • B2 — a local composite action was opaque. For uses: ./path the derivation hashed the target action.yml for integrity but never opened it, and the file enumeration only ever covered .github/workflows plus the root action.yml. A hidden-nested/action.yml containing uses: attacker-controlled-org/definitely-not-trusted@main raised active_sites from 29 to 30 and passed, with neither the owner nor the mutable ref appearing anywhere in the lock.

These are one defect wearing two hats: a policy expressed as a per-shape branch always has a shape whose branch forgot the check. Round 3 deletes the branches instead of adding a third one.

1. Transitive closure over uses:

derive_lock no longer enumerates a fixed file list. It seeds a worklist with .github/workflows/** plus the root action.yml, and every local uses: target discovered while scanning any file is enqueued into that same worklist, iterated to a fixed point. The scanner used for a nested action.yml is the same scan_file used for a workflow — there is no second, lighter pass.

Cycle handling is structural, not heuristic: a path already enqueued is never enqueued again, so A -> B -> A terminates; and a path being dequeued twice is a hard reject (closure-revisit). At the fixed point four counts must agree — head == |queue|, |visited| == |queue|, |visited| == |enqueued|, |visited| >= |seeds| — or the closure is declared a lie (closure-not-drained, closure-visit-drift, closure-fixpoint-drift, closure-below-seeds).

An independent second opinion runs after the entries are built: every entry whose resolved local target is non-empty must have that target inside the closed file set, or the run exits 1 with closure-incomplete. The two counters that guard that loop use two different predicates (non-empty resolved target vs kind == local) and must agree. So a closure that silently missed an edge fails rather than passes.

Each file in the lock carries file_NNN_origin: seed | closure, so which files entered by closure is visible in the reviewed artifact rather than implied.

2. One origin policy, zero branches

There is a single allowlist table keyed by (source, owner):

github-remote       actions
github-remote       ruby
github-remote       Mpaape
container-registry  docker.io/library
repository          in-tree

origin_is_trusted is called for every derived entry with no conditional whatsoever. entry_kind may change how the owner is extracted; it can never change whether the owner is judged. repository/in-tree is in the table so that an in-repo composite action is allowed by the rule rather than exempt from it — and its contents are then closed over by (1), so "in-tree" is a claim about provenance, not a licence to hide.

docker:// references are normalised to <registry>/<namespace…>/<image> before judgement: alpine becomes docker.io/library/alpine with owner docker.io/library; evil-registry.example.com/malware keeps owner evil-registry.example.com; docker.io/evilns/img gets owner docker.io/evilns. Neither of the latter two is in the table, so both exit 1 with source_integrity_error.

Unknown source, unknown owner, an empty argument, and an empty table all fail closed.

3. The gate proves the check ran

Not running a check is itself a failure mode with a typed code. The policy loop counts its own applications and refuses unless origin_checked == reftype_checked == |entries| and origin_checked > 0. The count is emitted into the lock as origin_policy_checked:, and Phase 1b of the gate re-asserts origin_policy_checked == active_site_count > 0 from the committed bytes. Every passing fixture is held to the same equality. So "the policy silently did not happen" cannot look like a pass anywhere.

What is scanned

The seed set is exactly the card's read_paths:

  • action.yml — the composite action manifest at the repository root
  • every regular file under .github/workflows, at any depth, with any extension or none (there is no *.yml filter, so EVIL.YML and a/b/c/deep.yml are scanned)

The scanned set is the transitive closure of that seed set under local uses: edges: every action.yml / action.yaml reachable through a chain of uses: ./… references is scanned by the same code, to any depth, cycles included.

Files are emitted in LC_ALL=C sorted order. The scan is byte-oriented and bounded: at most 256 input files (seeds and closure), at most 256 derived entries, at most 4 MiB in total. Crossing a bound exits 1 with input_limit_exceeded.

Local path resolution is component-by-component: .. anywhere refuses (local-action-path-escape), an empty or . segment refuses (local-action-path-malformed), any symlinked component — not only the last — refuses (local-action-path-symlink), a missing component refuses (local-action-missing), and a resolved target outside the scan root refuses (local-action-outside-root). The number of components walked is cross-checked against a separately computed segment count.

find -type f silently omits symlinks, which is precisely how a symlinked workflow file would escape a naive digest sweep. The derivation therefore enumerates symlinks separately and refuses them:

  • any symlink anywhere under .github/workflows → exit 1 source_integrity_error
  • .github/workflows itself being a symlink → exit 1 source_integrity_error
  • action.yml being a symlink → exit 1 source_integrity_error
  • any declared card path (.board/bootstrap/locks/actions.yml, tests/bootstrap/locks/AUR-361, tests/acceptance/AUR-361.sh, docs/specs/AUR-361.md) being a symlink → exit 1 source_integrity_error

Fixtures adv_symlink and symlinked_action_manifest exercise both shapes. .board/bin/oci-run independently refuses a symlinked acceptance program and refuses any symlink whose target is outside the materialised allowlist, so the container path is covered twice.

Byte hygiene

A NUL byte cannot be detected with BusyBox awk/grep (they truncate) or with bash read (it drops the byte silently). The derivation counts NUL bytes with tr -cd '\000' | wc -c and refuses any input with a non-zero count (invalid_lock_or_manifest). Carriage returns are counted the same way and refused, so a CRLF rewrite of a workflow cannot shift the line numbers the lock records. Path names containing a newline are caught by a count assertion: the number of lines from find -type f must equal the number of NULs from find -type f -print0, and a disagreement exits 1.

The uses: scanner

Derivation is line-based on purpose. A YAML object model — which the previous round used — loses every document after the first --- and attributes merge-key reuse to the wrong site. A line scan loses neither. Each line is processed in three stages:

  1. Full-line comment. If the first non-whitespace character is #, the line is inert by YAML's own rule. If it nonetheless carries the textual token uses: it is recorded as an inert_NNN_* witness so that even commented references are locked bytes.
  2. Unsupported construct. On a line that carries the token uses, an explicit key indicator (?), a merge key (<<:), or a YAML anchor/alias in key or value position exits 1 with invalid_lock_or_manifest. These are the constructs the scanner cannot reason about, so they are rejected rather than skipped — "no blind spot" is enforced, not claimed.
  3. Flow segmentation. The line is split at {, [ and ,. A segment is an active uses: key iff, after stripping leading whitespace and the structural characters -, ", ', it begins with uses, then an optional quote, then optional whitespace, then :.

Splitting cannot create a false negative: splitting only produces more segments, and a real uses: key always begins its own segment. It can produce a false positive (a uses: inside a quoted scalar that follows a comma); that direction is fail-closed and is the deliberate bias.

Forms proved by fixture: double-quoted value, single-quoted value, quoted key with space before the colon ("uses" : "…"), full-line comment, uses: inside a run: string, zero indentation, twenty-space indentation, no space after the colon, single-line flow mapping with uses first, single-line flow mapping with uses last, multi-line flow mapping, multi-document stream, uppercase filename, deep subdirectory, NUL byte, CRLF, alias value, merge-key reuse of an anchored uses:.

Known and declared residue: the merge site <<: *tpl is not counted as a separate site. It cannot smuggle anything, because the anchor's own uses: line is scanned and judged (fixture anchor_merge shows the anchored floating tag being refused), and any merge line is rejected outright when it carries the token uses.

Reference classification and policy

Two independent gates apply to every entry, in this order: the origin gate (is this owner allowed to supply code to this repository at all?) and the ref gate (is this reference immutable?). Neither is conditional on the shape.

Origin gate — the (source, owner) allowlist above, applied to all entries:

shape source derived owner verdict
actions/checkout@… github-remote actions allowed
some-untrusted-org/x@… github-remote some-untrusted-org exit 1 source_integrity_error
docker://alpine@… container-registry docker.io/library allowed
docker://evil-registry.example.com/malware@… container-registry evil-registry.example.com exit 1 source_integrity_error
docker://docker.io/evilns/img@… container-registry docker.io/evilns exit 1 source_integrity_error
./path repository in-tree allowed, and the target is then scanned by closure

Ref gate — applied to all entries after the origin gate:

shape ref_type requirement
owner/repo@<40 hex> commit_sha accepted
owner/repo@<7..39 hex> abbrev_sha exit 1 action_ref_mutable
owner/repo@<anything else> mutable_tag frozen-debt register only
./path local_path resolves to a real action.yml/action.yaml; the lock binds its sha256 and its uses: lines are derived transitively; missing, escaping or symlinked → source_integrity_error
docker://img@sha256:<64 hex> docker_digest accepted only if the normalised registry/namespace is in the origin allowlist
docker://img:tag mutable_tag exit 1 action_ref_mutable
anything else — exit 1 invalid_lock_or_manifest

An untrusted origin exits 1 with source_integrity_error even when the reference is perfectly pinned — fixture untrusted_owner pins some-untrusted-org/some-action@11bd7190…, fixture docker_untrusted_registry pins a full 64-hex manifest digest, and fixture nested_local_deep pins a full commit SHA two composite-action levels down. All three are refused. A sha256: digest proves which bytes; it never proves whose bytes, so pinning alone is not a substitute for provenance.

The frozen floating-tag debt register

.github/workflows/ci.yml and .github/workflows/documentation.yml predate this card and reference trusted actions by mutable tag. The card's Non-goals forbid AUR-361 from rewriting a mutable uses:, and .github/workflows is a read_path here, so the debt cannot be repaid inside this card. It is frozen instead, as a hard-coded constant in tests/acceptance/AUR-361.sh:

actions/checkout@v4              3
actions/setup-go@v5              3
actions/upload-artifact@v4       2
actions/upload-pages-artifact@v3 1
actions/github-script@v7         1
actions/deploy-pages@v4          1
ruby/setup-ruby@v1               1

A floating reference is tolerated only if its identity@ref appears here and the derived count is less than or equal to the recorded count. Repaying debt is always allowed; growing it is not. actions/checkout@v4 at four sites exits 1 (debt_overflow), actions/checkout@main exits 1 (mutable_tag), actions/checkout@v9 exits 1 (uppercase_ext, deep_subdir). The register lives in the acceptance program, not in the lock, so widening it is a visible diff in a reviewed path and can never be done by regenerating the lock.

Closing this debt is .board/cards/backlog/AUR-306's job (it replaces those workflows with SHA-pinned ones); this card only stops it from growing.

Lock schema

# GENERATED by tests/acceptance/AUR-361.sh --generate. Do not hand-edit.
# Every field below is derived from the bytes of the scanned inputs.
schema: bootstrap-lock-v1
card: AUR-361
derivation: uses-closure-derived-v2
seed_file_count: <s>
file_count: <n>                                         (n >= s: closure)
input_bytes: <total>
origin_policy_checked: <a>                              (must equal <a> below)
file_NNN_path / _origin / _bytes / _digest              (n quadruples)
active_site_count: <a>
inert_line_count: <i>
entry_NNN_file / _line / _kind / _source / _owner / _identity /
            _ref / _ref_type / _target / _line_digest   (a decuples)
inert_NNN_file / _line / _line_digest                   (i triples)

file_NNN_origin is seed or closure. entry_NNN_target is the closed-set path a local uses: resolved to, or - for a non-local entry; origin_policy_checked is the number of times the single origin gate fired and is re-asserted against active_site_count by the gate itself.

Ordering is total and deterministic: files in LC_ALL=C path order, entries in (file, line) order, inert witnesses likewise. --generate run five times in a row produces five identical sha256 digests.

Every section is derived. There is no section a human is expected to maintain, and therefore no section whose staleness can be exploited.

Error codes

code meaning
input_limit_exceeded more than 256 files (seeds plus closure), more than 256 entries, or more than 4 MiB
invalid_lock_or_manifest NUL or CR byte, hostile path, unsupported YAML construct, unparseable uses: value or docker image, empty uses: value, zero derived sites, no input files, a closure/scan/policy count assertion failing (closure-*-drift, origin-policy-drift, reftype-policy-drift, origin-policy-never-ran, scan-*-drift), a declared card path missing or empty, or a fixture expectation not met
source_integrity_error symlinked input, symlinked local path component, declared path symlink, non-regular input, untrusted origin of any kind (remote owner, container registry/namespace, or a shape whose source is unknown), missing or escaping local action manifest, a local target outside the closed set (closure-incomplete), or a lock/derivation divergence that is neither a site nor an identity nor a ref change
action_ref_mutable abbreviated SHA, floating tag outside the frozen register, floating tag above its registered count, or docker:// without a digest
action-identity-mismatch drift label: the derived identity at a committed (file, line) differs
action-ref-mutable drift label: the derived ref at a committed (file, line) differs
action-unlocked drift label: a derived site has no committed counterpart, or vice versa

The three hyphenated labels are the card's MUT-001..003 names. They are labels only: they are produced by classify_drift, which runs strictly after cmp has already decided the exit code is 1. classify_drift cannot select a pass, and the pass path never calls it. It keys on (file, line), so an identity that also occurs elsewhere in the same file — which the previous round's file-wide substring search confused — no longer blurs the label.

What the sealed gate actually does

./.board/bin/oci-run --profile bootstrap-readonly-v1 --card AUR-361 runs bash tests/acceptance/AUR-361.sh inside bash@sha256:ae4668c2560999e65e89532cd2ad1b6688bb23298189f0bd229ef80fa4bd0831 (Alpine 3.22 + BusyBox, no Go toolchain), network none, read-only rootfs, all capabilities dropped, non-root, with only the card's paths and read_paths materialised. Consequently:

  • There is no Go engine in this design. Round 1 leaned on tests/integration/AUR-361_test.go for the semantic check; that file was outside the card's declared paths and was invoked by no gate that blocks a card transition, so it was decorative. All semantics now live in the single program the sealed gate actually executes.
  • --generate cannot run inside the container (read-only rootfs). It is an author-side command; the gate only ever re-derives into /tmp and compares.

Phases, in order:

  1. every declared card path exists, is not a symlink, is non-empty, and is actually read (byte counts accumulated, loop count asserted)
  2. re-derive the whole lock from the live tree — seed enumeration, transitive closure to a fixed point, entries, then the single origin+ref policy — and cmp against the committed lock; divergence → classify → exit 1 1b. assert from the committed bytes that origin_policy_checked equals active_site_count and that both are non-zero
  3. run all 37 fixture cases from tests/bootstrap/locks/AUR-361/cases.tsv, asserting exit, code, active count, inert count, identity list, the origin-gate count, and — for every refusing row — the named control that had to fire; the number of cases executed must equal the number of rows declared and must be at least 36, and the number of named controls asserted must equal the number of refusing rows and be non-zero
  4. emit one bounded JSON line on stdout carrying named_controls (how many refusing fixtures named the control that fired), seed_files, closure_files, active_sites and origin_checks

Fixtures and reference truth

tests/bootstrap/locks/AUR-361/cases.tsv is hand-written from reading the fixture YAML. It is not generated by the acceptance program and it is not a copy of the lock. This is the point: the previous round's nominal fixture was documented as "an exact copy of the real lock", which made the nominal comparison a tautology. Here the reference truth for the live tree is the workflow files (the lock is the derivative), and the reference truth for the fixtures is a human claim in cases.tsv (the derivation is what is on trial).

Round 3 fixtures, one per blocker plus the controls that prove the mechanism rather than the instance:

fixture proves
docker_untrusted_registry B1: a fully digest-pinned image from a foreign registry is refused
docker_untrusted_namespace B1: docker.io alone is not trust; the namespace is judged
nested_local_hostile B2: a uses: inside a local composite action is derived and is policed
nested_local_deep B2: the closure is a fixed point, not one extra level — depth 2 is caught
nested_local_cycle A -> B -> A terminates and passes, so cycle handling is not "reject anything recursive"
nested_local_trusted positive control: a legitimate nested composite still passes, with both sites recorded
zero_uses / no_inputs Law 12: an empty closure and an empty seed set are FAILURES, not clean runs

The reason column, and why a typed code was not enough

Round 3 mutation-tested its own controls and found that asserting the typed code alone let two mutants live. Deleting the no-input-files refusal, or deleting the zero-uses-sites-derived refusal, kept the suite green: a different, redundant control (seed-duplicate, origin-policy-never-ran) refused with the same code invalid_lock_or_manifest, and no row could tell the two apart. Behaviour was still fail-closed, but the evidence could not observe a control that had stopped running — the surviving class one level up.

Every refusing row therefore carries a reason: the exact control that must fire. It is asserted as a colon-delimited field of the refusal detail, not as a substring (a substring test would match local-action-missing inside local-action-missing-manifest), and a count assertion requires one named control per refusing row, at least one overall. A refusing row without a reason, or a passing row with one, is itself a failure (fixture-reason-missing, fixture-reason-unexpected), so the column cannot be quietly emptied. Both mutants die on it:

detail fixture-reason:no_inputs:want=no-input-files:detail=seed-duplicate:0/1
detail fixture-reason:zero_uses:want=zero-uses-sites-derived:detail=origin-policy-never-ran

The 24 reasons were written by hand from the fixture YAML and the policy before the suite was run, and all 24 matched on the first execution.

Reproducing offline

# regenerate the lock after a legitimate workflow change
bash tests/acceptance/AUR-361.sh --generate

# the card's declared acceptance, sealed
./.board/bin/oci-run --profile bootstrap-readonly-v1 --card AUR-361

Diagnosis

Failures print two bounded lines on stderr and nothing else:

AUR-361/AC-001/<code>
AUR-361/AC-001/detail <file>:<line>:<reason>

No workflow content, no environment, no secret and no credential-shaped value is ever echoed — only paths, line numbers, counts and the typed reason.