TL;DR
Fleet policy has two gaps, and both are security gaps.
- It has no compliant way to verify publisher lineage. edgeproc-core and privacy-core added a
run: step to publish.yml that proves the candidate came from a successful release-candidate.yml dispatch on main. Fleet policy reports that step as shell-step, pypi-shape and npm-shape, and privacy-core also gets remote-module-identity and typed-oidc. Publishers without the check (edge-proc, assay) pass.
- It accepts a
${{ inputs.* }} expression inside dagger-for-github args. The action pastes args into bash (action.yml at 27b130bf, lines 141 and 177), so assay/.github/workflows/release-candidate.yml:34 (--tag=${{ inputs.tag }}) is script-injectable and passes the policy.
1. Publisher lineage
The publisher job's only gate is workflow_run.head_branch == default_branch. According to edgeproc-core #56 and privacy-core, a workflow_dispatch on a tag named main satisfies that gate. That dispatch runs the tag commit's own release-candidate.yml, so it can upload arbitrary bytes, and the main-branch publish.yml then publishes them through OIDC. I have not reproduced this myself.
The fix in those consumers is a run: step that checks two things with gh api and jq: the triggering run is a successful dispatch of release-candidate.yml in this repo for exactly head_sha, and compare/head_sha...GITHUB_SHA is ahead or identical.
Fleet policy cannot express this today.
validate_steps forbids every run: step.
- The allowed PyPI shapes are
(download, pypi) and (download, Dagger, pypi). The only Dagger identity allowed there is github.com/hseshadr/<repo>@${{ github.event.workflow_run.head_sha }}, which is the candidate's own code. In the tag-named-main attack, the attacker writes that code, so a lineage check running in it proves nothing. privacy-core deliberately loads its publisher from @$GITHUB_SHA (the main commit the workflow runs on), and the policy reports that as remote-module-identity.
Proposed direction (needs a decision; not implemented):
- Add a central, literal-SHA-pinned Dagger function, for example in
portfolio-foundation or a new publisher-lineage module in APPROVED_PUBLISHER_MODULES. It takes github-token, run-id, head-sha and main-sha as typed args and fails unless both lineage facts hold.
- Allow
(download, Dagger[approved lineage module], pypi) and require that shape for every publisher. Once every consumer has migrated, reject the bare (download, pypi) shape.
- Accept a consumer publisher loaded at
@${{ github.sha }} in addition to @${{ github.event.workflow_run.head_sha }}, or instead of it.
Until this lands, the publish.yml findings on edgeproc-core (2) and privacy-core (6) are expected. Removing their lineage step to satisfy the policy would reopen the hole.
2. Expressions in Dagger args
Add a finding such as dagger-args-expression. It should reject any ${{ inputs.* }}, ${{ github.event.* }} or ${{ github.head_ref }} in any dagger-for-github with: value. edge-proc#76 and the edgeproc-core and privacy-core fix/fleet-policy-findings PRs show the compliant shape: env: TAG: ${{ inputs.tag }} plus args: ... --tag="$TAG". Their tests run the real args through bash with hostile tags. assay would need the same change.
Found while classifying the 2026-09-25 live fleet scan findings (#48).
🤖 Generated with Claude Code
https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a
TL;DR
Fleet policy has two gaps, and both are security gaps.
run:step topublish.ymlthat proves the candidate came from a successfulrelease-candidate.ymldispatch onmain. Fleet policy reports that step asshell-step,pypi-shapeandnpm-shape, and privacy-core also getsremote-module-identityandtyped-oidc. Publishers without the check (edge-proc, assay) pass.${{ inputs.* }}expression insidedagger-for-githubargs. The action pastesargsinto bash (action.ymlat 27b130bf, lines 141 and 177), soassay/.github/workflows/release-candidate.yml:34(--tag=${{ inputs.tag }}) is script-injectable and passes the policy.1. Publisher lineage
The publisher job's only gate is
workflow_run.head_branch == default_branch. According to edgeproc-core #56 and privacy-core, aworkflow_dispatchon a tag namedmainsatisfies that gate. That dispatch runs the tag commit's ownrelease-candidate.yml, so it can upload arbitrary bytes, and themain-branchpublish.ymlthen publishes them through OIDC. I have not reproduced this myself.The fix in those consumers is a
run:step that checks two things withgh apiandjq: the triggering run is a successful dispatch ofrelease-candidate.ymlin this repo for exactlyhead_sha, andcompare/head_sha...GITHUB_SHAisaheadoridentical.Fleet policy cannot express this today.
validate_stepsforbids everyrun:step.(download, pypi)and(download, Dagger, pypi). The only Dagger identity allowed there isgithub.com/hseshadr/<repo>@${{ github.event.workflow_run.head_sha }}, which is the candidate's own code. In the tag-named-mainattack, the attacker writes that code, so a lineage check running in it proves nothing. privacy-core deliberately loads its publisher from@$GITHUB_SHA(themaincommit the workflow runs on), and the policy reports that asremote-module-identity.Proposed direction (needs a decision; not implemented):
portfolio-foundationor a newpublisher-lineagemodule inAPPROVED_PUBLISHER_MODULES. It takesgithub-token,run-id,head-shaandmain-shaas typed args and fails unless both lineage facts hold.(download, Dagger[approved lineage module], pypi)and require that shape for every publisher. Once every consumer has migrated, reject the bare(download, pypi)shape.@${{ github.sha }}in addition to@${{ github.event.workflow_run.head_sha }}, or instead of it.Until this lands, the
publish.ymlfindings on edgeproc-core (2) and privacy-core (6) are expected. Removing their lineage step to satisfy the policy would reopen the hole.2. Expressions in Dagger args
Add a finding such as
dagger-args-expression. It should reject any${{ inputs.* }},${{ github.event.* }}or${{ github.head_ref }}in anydagger-for-githubwith:value. edge-proc#76 and the edgeproc-core and privacy-corefix/fleet-policy-findingsPRs show the compliant shape:env: TAG: ${{ inputs.tag }}plusargs: ... --tag="$TAG". Their tests run the real args through bash with hostile tags. assay would need the same change.Found while classifying the 2026-09-25 live fleet scan findings (#48).
🤖 Generated with Claude Code
https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a