Skip to content

test: merge-gate the SEC-4045 workflow injection and action pinning fixes - #52

Merged
stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates
Sep 13, 2026
Merged

stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Summary

Merge-gate regression tests for the SEC-4045 CI findings, plus a check of what
the exploits the programme reproduced are already gated by.

The one gap was the CI half. .github/actions/verify-trust/action.yml
interpolated ${{ inputs.* }} into its run: script (VGI-H3), and every
workflow named actions by mutable tag (VGI-N2). #42 and #45 fixed both by
changing the shape of the YAML — values moved into env:, refs replaced by
commit SHAs. Nothing asserted that shape, so a future edit could undo it and
still merge green. This adds a structural gate that does.

The three did-git-sign findings turned out to be gated already, by tests #44
shipped with its own fix. Rather than write a second copy, I reverted each fix
and checked the existing gate fails; the results are below. No revert survives
in this branch — git diff origin/main is the four files in the diff.

What is new

crates/verify-trust/tests/ci_workflow_hardening.rs, four tests, and
yaml-rust2 as a workspace dev-dependency.

The tests read every *.yml / *.yaml under .github/workflows/ and
.github/actions/, parse each one, and walk the resulting tree. Composite
actions are covered as well as workflows, because that is where the injection
was, and where a run: script is most exposed: its inputs come from whoever
calls the action.

no_expression_is_interpolated_into_a_run_script

Guards VGI-H3, reproduced by gha_injection_sim.sh: GitHub substitutes an
expression into a script as raw text before any shell parses it, so an input
holding $(…) becomes a command. Asserts no ${{ }} appears inside any run:
scalar. The fix is to bind the value in env: and reference it as a quoted
variable, and the failure message says so.

every_action_reference_is_pinned_to_a_commit_sha

Guards VGI-N2. Asserts every uses: names a 40-hex commit SHA. Only a local
./… path is exempt: it resolves inside this repository, at the commit under
test. Anything else — a tag, a branch, or a docker:// image without a digest —
fails and gets looked at, because in a job holding contents: write or an OIDC
token a mutable ref runs whatever the action's owner points it at after review.

the_audit_reads_every_workflow_and_action_definition

Both checks above pass when they find nothing, so this asserts they found
something: workflows and actions both contributed files, and the walk reached
real run: and uses: scalars. Without it, renaming a directory or breaking
the walk would turn the gates green rather than red.

the_checks_flag_a_reintroduced_injection_and_ignore_prose

Runs both checks over an inline fixture carrying ${{ }} four times outside a
script (a YAML comment, an input description, an input default, an env:
binding) and once inside one, plus a pinned, a local and a floating uses:. It
asserts exactly one finding from each check, and that each names the offending
step.

This is why the gate parses instead of grepping. The fix for the injection left
a comment in action.yml saying ${{ }} must not appear in a script body, and
several input descriptions mention expressions too; a text search reports all of
them, which is how a string-matching check earlier in this programme produced
false hits on its own explanatory prose. A YAML comment belongs to no scalar, so
the tree-walk ignores prose — while still catching a ${{ }} hidden in a shell
comment inside a run: block, which GitHub does substitute.

Failures name the file, the position in the tree, and the step's name::

.github/workflows/ci.yml: jobs.test.steps[4].run: `run:` interpolates
  ${{ github.event.pull_request.title }} — bind it in `env:` and reference it
  as a quoted shell variable instead

action-selftest.yml (#42) already catches the injection at runtime, by calling
the action with shell syntax in its inputs. This is the complementary half: it
covers the files no runtime test exercises, and it fails at review time without
needing a runner.

Red, then green

Every revert below was undone in the same shell invocation that applied it, by a
trap, and each phase printed git diff origin/main -- <file> afterwards to prove
the file was back. The reverts never left the working tree.

Injection gate — ${{ inputs.range }} back in the action's run:

-          --range "$VT_RANGE"
+          --range "${{ inputs.range }}"

test no_expression_is_interpolated_into_a_run_script ... FAILED

1 `run:` script(s) interpolate a GitHub expression:
  - .github/actions/verify-trust/action.yml: runs.steps[1].run ("Run verify-trust"): `run:` interpolates ${{ inputs.range }} — bind it in `env:` and reference it as a quoted shell variable instead

test result: FAILED. 3 passed; 1 failed
real	0m0.250s

Pinning gate — Swatinem/rust-cache back to a mutable tag

-      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+      - uses: Swatinem/rust-cache@v2

test every_action_reference_is_pinned_to_a_commit_sha ... FAILED

2 action reference(s) are not pinned to a commit SHA:
  - .github/workflows/ci.yml: jobs.clippy.steps[2].uses: `uses: Swatinem/rust-cache@v2` is not pinned — give it the 40-hex commit SHA of the release, with the version in a trailing comment
  - .github/workflows/ci.yml: jobs.test.steps[2].uses: `uses: Swatinem/rust-cache@v2` is not pinned — give it the 40-hex commit SHA of the release, with the version in a trailing comment

test result: FAILED. 3 passed; 1 failed
real	0m0.282s

Injection gate, in a workflow, to show the job in the message

-      - run: cargo test --workspace
+      - run: cargo test --workspace -- --skip "${{ github.event.pull_request.title }}"

test no_expression_is_interpolated_into_a_run_script ... FAILED

1 `run:` script(s) interpolate a GitHub expression:
  - .github/workflows/ci.yml: jobs.test.steps[4].run: `run:` interpolates ${{ github.event.pull_request.title }} — bind it in `env:` and reference it as a quoted shell variable instead

test result: FAILED. 3 passed; 1 failed
real	0m0.202s

Green, on this branch: 4 passed; 0 failed … finished in 0.00s.

Hermetic when red as well as green. The gate reads files and parses them. It
opens no socket, spawns no process, and resolves no name, in either state — the
red runs above took 0.20–0.28 s wall clock including cargo's own overhead, and
the test binary itself reported 0.00 s.

The did-git-sign findings were already gated

Each fix from #44 was reverted, the existing suite run, and the revert undone in
the same invocation.

Reverted Existing gate Result
bypass_requested() reads DID_GIT_SIGN_BYPASS_POLICY unconditionally again (H2) tests/policy_bypass.rs::normal_builds_ignore_the_bypass_env FAILED, allowed: true, bypass: true
parent_is_allowed back to starts_with("git") (H1) policy::tests::programs_that_merely_start_with_an_allowed_name_may_not_sign, policy::tests::other_or_unknown_parents_are_refused both FAILED on gitleaks
check_namespace accepts every namespace (H1 friction) sign::tests::only_the_git_namespace_may_sign, sign::tests::a_non_git_namespace_is_refused_before_anything_is_read both FAILED

Adding a second set would have duplicated them, so this PR does not. Those red
runs were hermetic too: the namespace revert lets handle_sign continue past
the check, and it stops at the missing buffer file — failed to read file to sign: /nonexistent/did-git-sign/buffer — before the VTA is contacted. 1.9 s of
tests, no egress.

What is not gated, and why

H1's agreed remediation has not shipped. The plan's real fix is to sign
inside the VTA. sign.rs still calls vta::get_signing_key
get_key_secret, so the Ed25519 seed still reaches the client, and a process
running as the user can spoof a parent named git — or just run real git — and
get a signature. live_signing_oracle.txt reproduces exactly that, and the
behaviour is unchanged on main. There is no gate to write for it: what #44
shipped is honest documentation plus two pieces of friction, and those are what
the table above covers. This stays open as the accepted residual risk the
remediation plan describes, tracked by the VTA-side signing work.

One hole in the H2 gate is closed only by CI, not by cargo test.
normal_builds_ignore_the_bypass_env is #[cfg(not(feature = "insecure-policy-bypass"))], so if that feature were ever added to
[features] default, the test would disappear rather than fail and
cargo test --workspace would stay green. What catches it is ci.yml's
cargo build --release --locked -p did-git-sign step, which hits the
compile_error! in policy.rs, and the same build inside cargo package on
publish — so the bypass cannot reach a release either way. Closing it inside
cargo test needs a TOML parser to read the manifest; I have not added a second
parser dependency for it. Flagging it rather than leaving it implied.

Verification

Command Result
cargo fmt --all --check exit 0
cargo clippy --workspace --all-targets -- -D warnings exit 0
cargo test --workspace 162 passed, 0 failed

Rebased onto origin/main at dd3ea2b (vta-sdk 0.38.0, didwebvh-rs 0.6.1
dropped). The lockfile diff is yaml-rust2 and its two transitive crates,
nothing else. rand stays at 0.8 — pgp 0.20 needs rand_core 0.6 RNGs.
No keys or private material; the tests read only files already in the repository
and one inline fixture.

The workflow-injection regression test asks a question about the YAML
tree — "does any `run:` scalar contain a `${{ }}` expression" — so it
needs a parser. Matching the text instead finds the same marker in a
comment or an input description and reports it as a finding; the
verify-trust action carries exactly such a comment.

Added as a workspace dev-dependency and taken by verify-trust, which is
the crate the composite action wraps.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
SEC-4045 reproduced two CI findings: the verify-trust composite action
pasted `${{ inputs.* }}` into its `run:` script, where an input holding
`$(…)` became a command, and the workflows named actions by mutable tag
in jobs holding `contents: write` or an OIDC token. Both were fixed by
changing the shape of the YAML — values moved into `env:`, refs replaced
by commit SHAs — so the gate asserts that shape.

`action-selftest.yml` already catches the injection at runtime, by
calling the action with shell syntax in its inputs. This is the other
half: a structural check over every definition file, including the ones
no runtime test exercises, that fails at review time rather than needing
a runner.

The new test parses each file under `.github/workflows/` and
`.github/actions/` and walks the resulting tree:

  - no `${{ }}` expression appears inside any `run:` scalar;
  - every `uses:` names a 40-hex commit SHA, local `./…` paths aside.

It parses rather than greps because the injection fix left a comment in
action.yml explaining that `${{ }}` must not appear in a script body,
and several input descriptions mention expressions too. A text search
reports all of those. A YAML comment belongs to no scalar, so the
tree-walk ignores prose while still seeing a `${{ }}` hidden in a shell
comment inside a `run:` block, which GitHub does substitute.

Failures name the file, the position in the tree (`jobs.test.steps[4]`)
and the step's `name:`. Two further tests keep the gate honest: one
asserts the walk actually reached `run:` and `uses:` scalars in both
directories, so a rename cannot turn the checks green by leaving them
nothing to read; the other runs both checks over an inline fixture that
carries the marker four times outside a script and once inside one, and
asserts exactly one finding.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner September 12, 2026 21:16
@stormer78
stormer78 merged commit 48dbb11 into main Sep 13, 2026
7 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 13, 2026 05:04
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.

1 participant