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:
- The whole lock is generated.
tests/acceptance/AUR-361.sh --generateis a pure function of the bytes underaction.ymland.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. - The gate regenerates and compares byte for byte. The acceptance program
derives the complete lock into a temporary file and runs
cmpagainst 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 newuses:site changes the derived entry list, therefore the derived bytes, therefore the comparison. - 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
--generatefrom becoming the new bypass: regenerating the lock after addingsome-untrusted-org/some-action@mainmakes the byte comparison agree and then the derived-entry policy refuses the run anyway. - Emptiness is failure. Zero input files, zero derived
uses:sites, a missingaction.yml, a missing.github/workflows, nouses:insideaction.yml, or nouses:under.github/workflowseach exit 1. An AUR-361 with nouses: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_trustedwas called only whenentry_kind == remote. Forentry_kind == dockerthe owner was the hard-coded literal stringdocker, compared against nothing. Auses: 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: ./paththe derivation hashed the targetaction.ymlfor integrity but never opened it, and the file enumeration only ever covered.github/workflowsplus the rootaction.yml. Ahidden-nested/action.ymlcontaininguses: attacker-controlled-org/definitely-not-trusted@mainraisedactive_sitesfrom 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*.ymlfilter, soEVIL.YMLanda/b/c/deep.ymlare 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.
Symlink behaviour (declared and proved)
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 1source_integrity_error .github/workflowsitself being a symlink → exit 1source_integrity_erroraction.ymlbeing a symlink → exit 1source_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 1source_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:
- 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 tokenuses:it is recorded as aninert_NNN_*witness so that even commented references are locked bytes. - 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 withinvalid_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. - Flow segmentation. The line is split at
{,[and,. A segment is an activeuses:key iff, after stripping leading whitespace and the structural characters-,",', it begins withuses, 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.gofor the semantic check; that file was outside the card's declaredpathsand 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. --generatecannot run inside the container (read-only rootfs). It is an author-side command; the gate only ever re-derives into/tmpand compares.
Phases, in order:
- every declared card path exists, is not a symlink, is non-empty, and is actually read (byte counts accumulated, loop count asserted)
- 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
cmpagainst the committed lock; divergence → classify → exit 1 1b. assert from the committed bytes thatorigin_policy_checkedequalsactive_site_countand that both are non-zero - 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 - emit one bounded JSON line on stdout carrying
named_controls(how many refusing fixtures named the control that fired),seed_files,closure_files,active_sitesandorigin_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.