Skip to content

Harden CI: bind verify-published-wheel's dispatch input to env - #5744

Merged
vivekchand merged 6 commits into
mainfrom
harden/verify-wheel-dispatch-input-env
Sep 10, 2026
Merged

vivekchand merged 6 commits into
mainfrom
harden/verify-wheel-dispatch-input-env

Conversation

@vivekchand

@vivekchand vivekchand commented Sep 9, 2026

Copy link
Copy Markdown
Owner

verify-published-wheel.yml expanded its workflow_dispatch version input straight into the body of a shell: python step:

- name: Resolve version
  shell: python
  run: |
    want = "${{ github.event.inputs.version }}".strip()

A ${{ }} expansion is pasted into the program text before the interpreter starts, so the input arrived as program source rather than as data. This is the template-injection class that zizmor flags and that Scorecard reports as DangerousWorkflow. Triggering it needs write access (workflow_dispatch is not open to outsiders), so this is defence in depth rather than an exposed hole — but it is the same shape as the one #5673 closed in auto-deploy-cloud.yml, and the fix is the same three lines.

What changed

1. The input is bound to env, not expanded into the script. INPUT_VERSION is read with os.environ.get(...), so the value is data the program reads instead of text the interpreter compiles.

2. The resolved version is validated before it reaches GITHUB_OUTPUT. This step is the taint root for the whole job — every later step reads steps.ver.outputs.version — so what leaves it is checked against a bare-version pattern. That stops a value carrying a newline (which would forge a second step output) or shell metacharacters from propagating.

3. The verify step binds that output to env and quotes it. --version "$VERSION" instead of a raw ${{ }} expansion.

4. That step gets an explicit shell: bash. The job matrix includes windows-latest, where the default shell is pwsh and $VERSION would silently read as an unset PowerShell variable rather than the environment variable — an empty --version instead of a failure. conformance-heartbeat.yml and release-canary.yml both already run shell: bash steps unconditionally on their Windows legs, so this is established here.

The concurrency: group still interpolates the input, deliberately: a concurrency group is a name, not a program, and nothing evaluates it.

Verification

The embedded resolver was run directly against the real step body:

input result
0.12.844 accepted → version=0.12.844
1.0.0rc1 accepted → version=1.0.0rc1
1!2.0.0+local.1 (epoch + local) accepted
quote-escaping payload rejected, nothing written to GITHUB_OUTPUT
value containing a newline rejected, nothing written
$(id) rejected, nothing written

Blank input (the path a PR self-test takes) still resolves latest from PyPI — checked live, returning 0.12.844.

Every workflow file parses (yaml.safe_load over .github/workflows/*.yml), and tests/test_workflow_yaml_valid.py passes: 542 passed, 336 skipped.

This workflow is path-scoped to self-test on any PR that touches it, so this PR exercises the change on Ubuntu, macOS and Windows before it merges.

No-PRD: CI-only change, .github/ is exempt per scripts/check_product_record.py.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HfPzC2zA4eb21Gr66skCo7


Generated by Claude Code


The class, not the instance (added after Drift Bot)

#5673 fixed this exact construct in auto-deploy-cloud.yml. It came back here, because nothing wrote the rule down and nothing failed when it returned. Two occurrences is a pattern, so this PR now also closes the pattern:

The rule is recorded. "A workflow input is data, never program text" is a section on the Release Verification and Merge Gating blueprint, with three System Contracts (an input reaches a step through env:; a value written to GITHUB_OUTPUT is validated first; WorkflowIntegrityGuard covers the pattern) and an ADR for why the class is hardened rather than each occurrence.

The rule is enforced. tests/test_ci_workflow_invocations_are_real.py now rejects any ${{ }} expansion of an actor-influenced context — github.event.inputs.*, inputs.*, github.head_ref, the issue / pull-request / comment / review / discussion payloads — inside a run: step body. Scope is derived from the workflow files, so a new workflow is covered without touching the guard. Proven red against the exact construct this PR removes:

AssertionError: verify-published-wheel.yml: job 'verify', step 'Resolve version'
expands ${{ github.event.inputs.version }} directly into its `run:` body.

and green after the fix. SecurityAuditScanner already scans for script injection but is explicitly non-gating — scanning without gating produces a report nobody is obliged to act on, which is how the same shape reached a second workflow.

And the guard now runs. It was in no CI job at all. This repo runs explicit file lists, not pytest tests/, so the guard written for ADR-005 — "a CI step that cannot fail is worse than no CI step" — had never once executed, which is precisely the defect it exists to catch, on itself. It is now named in ci.yml's Syntax & Lint job: 7 passed.

`verify-published-wheel.yml` expanded the `workflow_dispatch` `version`
input straight into the body of a `shell: python` step. A `${{ }}`
expansion is pasted into the program text before the interpreter starts,
so the input was program source rather than data.

Bind it to `INPUT_VERSION` and read it with `os.environ` instead, the
same shape #5673 used for auto-deploy-cloud. The resolved value is then
checked against a bare-version pattern before it is written to
GITHUB_OUTPUT, so the downstream step cannot inherit a newline (which
would forge a second step output) or a shell metacharacter.

The verify step reads that output, so it is bound to env too and quoted
as `"$VERSION"`. It gets an explicit `shell: bash` because the job also
runs on windows-latest, where the default shell is pwsh and `$VERSION`
would read as an unset PowerShell variable rather than the environment
variable.

The workflow self-tests on any PR touching it, so all three platforms
exercise this on the PR itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfPzC2zA4eb21Gr66skCo7
@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: Release Verification and Merge Gating

File: .github/workflows/verify-published-wheel.yml:82

The PR hardens verify-published-wheel.yml against template injection by binding workflow_dispatch inputs to env variables and validating them before they reach GITHUB_OUTPUT—a security pattern not documented in the WorkflowIntegrityGuard component that scans workflows for script injection.

# env var is data the program only ever reads. This step is the taint
# root for the whole job -- the verify step below reads
# `steps.ver.outputs.version`, which is this value.
env:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Release Verification and Merge Gating

The PR hardens verify-published-wheel.yml against template injection by binding workflow_dispatch inputs to env variables and validating them before they reach GITHUB_OUTPUT—a security pattern not documented in the WorkflowIntegrityGuard component that scans workflows for script injection.

Copy link
Copy Markdown
Owner Author

Blocked on drift-bot; every other leg is green.

E2E Gate (required) failed for exactly one reason — it aggregates Drift Bot as a leg, and that leg is red:

[30s] Cross-repo handoff: passed: 1 leg(s) passed
[61s] Drift Bot: failed: 1 of 1 matching check(s) failed
FAIL: required checks did not pass:

At the same head (fb5143d): 30 checks green, 0 other failures, CodeQL neutral (no compiled-language change in a workflow-only diff). That includes all four verify-published-wheel self-test legs this PR triggers — ubuntu-latest / py3.9, ubuntu-latest / py3.11, macos-latest / py3.11 and windows-latest / py3.11. The Windows leg is the one that matters most here: it exercises the new shell: bash + "$VERSION" binding, which is the only part of the change with a platform-specific failure mode.

The finding

Drift Bot reports that the hardening pattern — binding the dispatch input to env and validating it before GITHUB_OUTPUT — is not documented in the WorkflowIntegrityGuard component of the Release Verification and Merge Gating blueprint.

That is a gap in the blueprint, not a defect in the diff. The finding does not dispute that the workflow expanded a workflow_dispatch input into a shell: python step body, nor that binding it to env closes that.

Why this is not fixed in this PR

Two reasons, in order:

  1. I cannot reach the Factory from this session. Per FLYWHEEL.md §1f, drift-bot is a commit status posted by the 8090-software-factory App against the head SHA — there is no re-run button and no workflow to re-trigger, so I have no means to re-run it either. Clearing it means editing the blueprint. The repo's own client (.claude/sf_client.py) reads SF_API_KEY, falling back to the macOS keychain entry clawmetry-sf-api-key; neither exists in this container. The API host itself is reachable — https://api.factory.8090.dev/v2/external-api answers 401, not a network error — so the only missing piece is that key.

  2. Widening the PR to satisfy the bot would be the wrong fix. Harden CI: run the full security-audit scanner (workflows, package-lock, Python source) #5733 already faced this and declined, correctly: adding scope at a documentation gate's request breaks the one-concern-per-PR discipline, and FLYWHEEL.md §0c is explicit that back-filling a record to turn the check green is the failure mode the gate exists to prevent — not a way to pass it.

What unblocks it

The WorkflowIntegrityGuard component needs to say that workflows bind untrusted inputs to env rather than interpolating them into run: bodies. That is one sentence of specification, and it retroactively covers #5305 and #5673 as well as this PR.

This PR is otherwise mergeable and green. It joins #5305 (red since 28 Aug), #5382 and #5733 in the same state — four hardening changes now held by the same blueprint gap rather than by four separate ones.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

blocked on author decision — skipping (auto-mergeability sweep)


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: Release Verification and Merge Gating

File: .github/workflows/verify-published-wheel.yml:82

The PR implements template injection hardening in verify-published-wheel.yml by binding workflow_dispatch inputs to env variables and validating them before GITHUB_OUTPUT, but the WorkflowIntegrityGuard component doesn't document checking for this security pattern in dispatch input handling.

# env var is data the program only ever reads. This step is the taint
# root for the whole job -- the verify step below reads
# `steps.ver.outputs.version`, which is this value.
env:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Release Verification and Merge Gating

The PR implements template injection hardening in verify-published-wheel.yml by binding workflow_dispatch inputs to env variables and validating them before GITHUB_OUTPUT, but the WorkflowIntegrityGuard component doesn't document checking for this security pattern in dispatch input handling.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged latest main into branch to keep it up to date


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: Release Verification and Merge Gating

File: .github/workflows/verify-published-wheel.yml:82

The PR implements template injection hardening for workflow_dispatch inputs (binding to env, validating before GITHUB_OUTPUT, explicit shell:bash), but the WorkflowIntegrityGuard component is not documented as checking for this security pattern in workflow definitions, despite its stated responsibility to scan workflows for script injection.

# env var is data the program only ever reads. This step is the taint
# root for the whole job -- the verify step below reads
# `steps.ver.outputs.version`, which is this value.
env:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Release Verification and Merge Gating

The PR implements template injection hardening for workflow_dispatch inputs (binding to env, validating before GITHUB_OUTPUT, explicit shell:bash), but the WorkflowIntegrityGuard component is not documented as checking for this security pattern in workflow definitions, despite its stated responsibility to scan workflows for script injection.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged latest main into branch (was BEHIND; no conflicts)


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: Release Verification and Merge Gating

File: .github/workflows/verify-published-wheel.yml:73

The PR implements template injection hardening for workflow_dispatch inputs (binding to env, regex validation before GITHUB_OUTPUT), but the blueprint's WorkflowIntegrityGuard component does not document checking for this security pattern in workflow dispatch input handling despite the SecurityAuditScanner component mentioning script injection scanning.

@@ -73,16 +73,35 @@ jobs:
- name: Resolve version

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Release Verification and Merge Gating

The PR implements template injection hardening for workflow_dispatch inputs (binding to env, regex validation before GITHUB_OUTPUT), but the blueprint's WorkflowIntegrityGuard component does not document checking for this security pattern in workflow dispatch input handling despite the SecurityAuditScanner component mentioning script injection scanning.

Copy link
Copy Markdown
Owner Author

Automated sweep note — E2E Gate blocked by Drift Bot

The E2E Gate (E2E Gate (required)) is failing because the Drift Bot sub-check has not passed on commit c43b0455. Drift Bot reports when a PR's code changes contradict a Blueprint or architecture doc; it cannot be unblocked by a code push unless the specific finding is addressed.

What to do:

  1. Check the Drift Bot annotation on this PR (the check run comment or the e2e-gate step output) to see which Blueprint claim is flagged.
  2. Either update the Blueprint/doc to match the new code, or adjust the code to match the existing Blueprint.
  3. Once the underlying drift is resolved, push a new commit — Drift Bot re-runs on each push.

If Drift Bot is flagging something spurious (e.g. a doc that was intentionally superseded), a No-Drift: <reason> line in the PR body may suppress it — check .github/workflows/e2e-gate.yml for the exact suppression syntax.

This PR is otherwise not in a DIRTY or BEHIND state from a git perspective; the only blocker is the Drift Bot check.


Generated by Claude Code

…ance

Drift Bot's finding was right: the blueprint's WorkflowIntegrityGuard did not
document checking workflow_dispatch input handling for template injection,
even though SecurityAuditScanner scans for script injection. Scanning without
gating produces a report nobody is obliged to act on.

The Release Verification and Merge Gating blueprint now carries the contract
(an input reaches a step through env:, never a ${{ }} expansion in the step
body; a value written to GITHUB_OUTPUT is validated first; the guard covers
the pattern) and an ADR recording why the class is hardened rather than each
occurrence: #5673 fixed this construct in auto-deploy-cloud.yml, it recurred
here, and nothing failed when it did.

Placed near the top of that record, per FLYWHEEL 0c.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6A5G74JiMe3zHFs1JZEP
@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: Release Verification and Merge Gating

File: .github/workflows/verify-published-wheel.yml:73

The PR implements the blueprint's template injection hardening pattern (env binding, regex validation before GITHUB_OUTPUT, explicit shell:bash), but the WorkflowIntegrityGuard component's documented responsibilities do not include checking for workflow_dispatch template injection patterns, contradicting the blueprint's assertion that the guard covers this pattern.

@@ -73,16 +73,43 @@ jobs:
- name: Resolve version

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Release Verification and Merge Gating

The PR implements the blueprint's template injection hardening pattern (env binding, regex validation before GITHUB_OUTPUT, explicit shell:bash), but the WorkflowIntegrityGuard component's documented responsibilities do not include checking for workflow_dispatch template injection patterns, contradicting the blueprint's assertion that the guard covers this pattern.

Drift Bot's second finding was sharper than the first: the blueprint now
asserted the guard covers workflow_dispatch template injection, but the
WorkflowIntegrityGuard component's responsibilities did not, so the record
claimed a coverage that did not exist.

It does now. tests/test_ci_workflow_invocations_are_real.py rejects any
${{ }} expansion of an actor-influenced context -- github.event.inputs.*,
inputs.*, github.head_ref, the issue/PR/comment/review/discussion payloads --
inside a run: step body. Scope is derived from the workflow files, so a new
workflow is covered without editing the guard. Proven red against the exact
construct this PR removes and green after it.

And the guard is now in a CI file list. It was in none. This repo runs
explicit file lists, not `pytest tests/`, so the guard written for ADR-005
-- 'a CI step that cannot fail is worse than no CI step' -- had never once
executed. That is the defect it exists to catch, on itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6A5G74JiMe3zHFs1JZEP
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand merged commit 6d40439 into main Sep 10, 2026
42 of 43 checks passed
@vivekchand
vivekchand deleted the harden/verify-wheel-dispatch-input-env branch September 10, 2026 03:11
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.

2 participants