Skip to content

ci: stop interpolating untrusted event data into shell, and seal evidential binaries - #2172

Open
gHashTag wants to merge 2 commits into
masterfrom
w699-untrusted-input
Open

ci: stop interpolating untrusted event data into shell, and seal evidential binaries#2172
gHashTag wants to merge 2 commits into
masterfrom
w699-untrusted-input

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

Closes #2171

What was wrong

notebook-sync.yml interpolated github.event.pull_request.title and
github.event.issue.title into a run: block. GitHub substitutes an expression
into the script text before bash parses it, so the author of a title chose
what the runner executed.

Measured with scripts/ci/test_untrusted_payloads.py, ten payloads whose
expected effect is declared in the source before the run:

payload interpolated form env: form
x$(touch M)y executes clean, verbatim
x`touch M`y executes clean, verbatim
a"; touch M; echo "b executes clean, verbatim
x$(touch${IFS}M)y executes clean, verbatim
x$(echo $GITHUB_TOKEN > M)y executes clean, verbatim
a "quoted" title destroys the script clean, verbatim
title of #2168, verbatim destroys the script clean, verbatim
it's a title, Name(T), newline control: passes both clean, verbatim

Five of ten execute a command. One of those five writes $GITHUB_TOKEN to a
file. The controls are there so the harness has to demonstrate it can tell the
cases apart; a payload the vulnerable form survives proves nothing about the
safe form, and the run fails if any payload does not match its declared class.

Correction to the record

#2171, the tick D report and a commit message all said the CI failure was caused
by the parenthesis in the title of #2168. That is wrong, and the last table
row refutes it: a parenthesis inside a double-quoted assignment is an ordinary
character.

The entry point was the backticks. They opened a command substitution, and
( was a syntax error inside it -- reproduced byte for byte against the CI log:

bash: command substitution: line 1: syntax error near unexpected token `('
bash: command substitution: line 1: `pub const Name(T) = struct'

This makes the finding worse, not better. Had the backticked text been a valid
command rather than a declaration, it would have run. The parenthesis is
what stopped it. Titles in this repository quote code in backticks as a matter
of style, so the dangerous construct is the ordinary one.

The fix

  • issue_title is deleted, not sanitised. It was published as a job output
    and read by nowhere in .github/ or contrib/backend/notebooklm/. Untrusted
    text that no step consumes is best not carried.
  • Remaining event fields move to env:; the runner sets them, so no character
    in them can be parsed as shell.
  • The issue number is validated against ^[0-9]+$ before it is written.
    GITHUB_OUTPUT is a newline-delimited file: a value containing a newline
    defines further outputs of its own choosing. Demonstrated at the end of the
    payload run. Quoting does not help -- the value is already data and the file
    format is what is abused.
  • SYNC_ARGS becomes an array. A string is re-split by the shell on whatever
    IFS happens to be, which is a second route from one value to many arguments.
  • extract-issue -- the job that reads untrusted data -- gets
    permissions: contents: read. It previously had none declared, so it received
    the repository default, while its sibling job demonstrably writes to the
    default branch.
  • Second vector, same class: l1-traceability.yml ran
    git fetch origin "${{ github.event.pull_request.head.ref }}:$HEAD_SHA".
    Measured with git check-ref-format --branch: x$(id)y, x`id`y,
    a;id;b, x$(cat${IFS}/etc/hostname)y, x|id, x&&id, x'y, x"y are all
    accepted as branch names. Only a space is forbidden, and ${IFS}
    substitutes for a space. The ref now goes through env:, is validated, and is
    refused if it could be read as an option.

The standing check

scripts/ci/check_untrusted_shell_interp.py classifies every ${{ }} inside a
run: block as untrusted, suspect or safe: 5 untrusted before this change, 0
after, across 35 workflows.
Warnings dropped 13 -> 2 as a side effect.

It carries no branches: filter, because a gate that filters pull_request
by branch does not run on a stacked PR and reads as green (#2167).

Its scope is stated in its own output: interpolation into shell only. It
does not check github-script, or JSON, YAML, Markdown or SQL written by a step.

E2: evidential binaries

/tmp/t27c.base and /tmp/t27c.fixed carried the difference behind several
loop reports and recorded neither their commit nor their profile. Two earlier
tools were already lost to a sandbox reset.

scripts/ci/artifact_seal.py records commit, dirty-tree flag, build commands,
toolchain versions, profile, SHA-256 and size of every artifact and declared
input, test results by digest of their output, and the date obtained.
scripts/ci/rebuild_evidence.sh builds from a named commit and seals in the
same step, so provenance is captured rather than reconstructed.

Verified end to end: t27c.master-baseline rebuilt from b928725 to the same
digest 836e8bc4....

That only worked after the build path was made a constant. A debug build
embeds its source path, so the same commit built from two randomly named
worktrees produced binaries differing in 39,830,933 bytes, and the sealed
binary literally contained /tmp/t27_evidence_5UEbqk. Digest comparison is a
usable verification method only from a fixed path; both the builder and the
verifier now default to /tmp/t27_seal_build.

Two failures of the tool were found by using it, and both are recorded in
comments where they happened:

  1. the seal stored the ephemeral worktree path as the repository, so --rebuild
    had nowhere to fetch from once the worktree was gone;
  2. --rebuild looked for the sealed filename in the rebuilt tree, did not
    find it, and printed unreproduced -- an absent comparison wearing the
    costume of a negative result. It now prints not-evaluated and the seal
    records produced_at.

The tick D pair is sealed with its commit field empty
(provenance: unrecorded-at-build-time). Provenance not captured at build time
cannot be recovered afterwards, and writing today's HEAD there would
manufacture it. verify --rebuild refuses such a seal instead of guessing.

What this PR does not claim

  • The two workflow files are not proven free of every injection class. The
    check covers shell interpolation. notebook-sync.yml still passes event data
    to actions/github-script, which is a different interpreter and out of scope.
  • permissions: was narrowed on one job only. A repository-wide default is a
    separate change.
  • auto-merge-ready-prs.yml does not parse as YAML (diffbin: 'unchanged' counted 286 unmeasured files as agreement; 52% coverage was reported as 100% #2166 work) and is untouched
    here on purpose.
  • The payload harness tests two shell forms, not the live workflow.

Note for review

docs/loop/LOOP-RULES.md does not exist on master -- it is in the unmerged
#2159 branch. Two rules earned here (a payload the vulnerable form survives
has no evidential force
, and an absent comparison may never print as a
comparison that failed
) are recorded in the loop ledger and belong in that
file when #2159 lands, rather than in a competing copy created here.

scripts/tri on master is a thin wrapper around t27c and has no loop
subcommands, so artifact_seal.py is invoked directly. Wiring it as
tri artifact-seal belongs with #2159 for the same reason.

…ential binaries

A pull request title was pasted into a run: block, so its author chose what the
runner executed. Measured over ten payloads: five execute a command under the
interpolated form, including one that writes GITHUB_TOKEN to a file; all ten
pass through an env: variable byte for byte with no side effect.

Corrects the record: the failure that exposed this was caused by the backticks
in the title of #2168, not by the parenthesis. Reproduced byte for byte. The
parenthesis is what prevented execution, which makes the finding worse.

The unused issue_title output is deleted rather than sanitised. Remaining event
fields move to env:, the issue number is validated before it is written to
GITHUB_OUTPUT, and SYNC_ARGS becomes an array. Second vector in
l1-traceability.yml: a branch name may contain shell metacharacters, so the ref
is validated before it reaches git.

check_untrusted_shell_interp.py: 5 untrusted interpolations before, 0 after,
over 35 workflows. It carries no branches: filter, so it also runs on stacked
pull requests.

artifact_seal.py records commit, build commands, toolchain, profile, digests
and test results, and can rebuild from the commit and compare. Reproduced
bit-exactly once the build path was made constant: the same commit built from
differently named worktrees differed in 39,830,933 bytes, because a debug build
embeds its source path.

Closes #2171
@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-14 22:21:01 UTC

Summary

Status Count
Total Open PRs 27
PRs with Failing Checks 11
PRs with All Checks Green 16
READY 8
FAILING 11
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=cd2822f290eb != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-15 09:00:49 UTC

Summary

Status Count
Total Open PRs 27
PRs with Failing Checks 11
PRs with All Checks Green 16
READY 8
FAILING 11
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=cd2822f290eb != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag
gHashTag force-pushed the w699-untrusted-input branch from 0b869e3 to fb8d62d Compare August 15, 2026 09:03
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-15 09:03:58 UTC

Summary

Status Count
Total Open PRs 27
PRs with Failing Checks 11
PRs with All Checks Green 16
READY 8
FAILING 11
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=cd2822f290eb != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: PR/issue titles are interpolated into a shell script in notebook-sync.yml (command injection)

1 participant