Skip to content

security(ci): a conditional step can assemble its command words and bypass the scan guard through positions the walk cannot enumerate #3582

Closed

Description

@devantler

🤖 Generated by the Agentic Engineer

The class

A conditional run: step can assemble its command words from fragments so that no scan text appears anywhere in the scalar, then execute the reduced scan through some syntactic position the guard's command-position walk does not reach:

K=ks; K+=ail
W=work; W+=load
S=sc; S+=an
F=--frame; F+=work
# Position Reproduction
1 wrapper option with a separate operand env -u UNUSED "$K" "$W" "$S" "$F" nsa
2 eval of assembled code C=…; eval "$C"
3 command substitution echo "$("$K" "$W" "$S" "$F" nsa)"
4 shell reserved word if "$K" "$W" "$S" "$F" nsa; then true; fi
5 the unconditional path's expansion gate "$K" "$W" "$S" "$F" nsa — no plain scan word and no literal --framework, so allExpandedBeforeFramework never runs

All five were found by review on #3546 and all five are real: bash executes a reduced scan that can overwrite the SARIF while a separate nsa,mitre invocation satisfies the validator.

Why this is filed rather than fixed

Patching positions does not converge. #3546 closed the spelling class by inverting the conditional screen from "recognise a scan" to "refuse unless shown scan-free". That worked. But once the attacker assembles command words from fragments, the remaining question is "which syntactic positions can hold a command", and enumerating those is the same losing game one level down — four of the five above are simply positions the previous round's walk did not visit, and there is no argument that a fifth round would be the last.

The obvious strong rule is not viable, and that is measured rather than assumed. Refusing any conditional step that contains any expansion closes the whole family at a stroke. It also refuses the real ci.yaml: its SARIF-normalising step is conditional and legitimately uses ${RUNNER_TEMP} and ${GITHUB_OUTPUT}. Implemented and run, it failed all three real-workflow tests. So the cheap terminal rule would reject the very workflow this guard exists to validate.

This is not a regression. Every one of these five is bypassable on main today — #3546 does not introduce them, it closes thirteen other bypasses while leaving these. Holding that work to avoid these would keep strictly more holes open.

Direction

The decidable question is not "does this text invoke the scan" but "is this step's shape simple enough to read at all". A conditional step could be required to match a small allow-list of provably-simple shapes — a literal command word, literal arguments, no substitution, no eval, no reserved-word compound — and refused otherwise, with the refusal naming the shape. That inverts at the level where the current design still enumerates, and its DevEx cost needs measuring against the real workflows before it is adopted.

Acceptance criteria:

  • All five reproductions above refuse.
  • The three real-workflow tests still pass, and the conditional steps in ci.yaml that legitimately use expansions still pass.
  • One shared fixture table drives the conditional and unconditional paths, so a position added for one is asserted against the other.
  • Whatever rule is chosen ships with a control proving it did not collapse into "refuse every conditional step", which every refusal test would otherwise pass.

Activity

  1. added theissue type on Sep 4, 2026
  2. moved this to 📥 Backlog in 🌊 Project Boardon Sep 4, 2026
  3. self-assigned this
    on Oct 3, 2026
  4. added 2 commits that reference this issue on Oct 3, 2026
  5. devantler commented on Oct 3, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Delivered by merged PR #4431, reviewed source commit 06d951f.

    The conditional and unconditional paths now consume one bounded AST analysis. The shared regression table refuses all five original assembled-command cases across both paths. Controls accept ordinary conditional steps, the legitimate environment expansions in the real workflows, and the three original real-workflow tests.

    Acceptance was checked against the original issue body. Current-head race tests passed all 266 test/subtest assertions, including 28 Bash recording witnesses; targeted safeguard mutations failed the intended regressions. The original historical test file is unchanged, and source CI plus protected merge-queue CI passed. Closing as completed.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

Priority

None yet

Effort

None yet

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions