Security Finding
Severity: high
Type: permission-issue (CI least-privilege / supply-chain blast radius)
File: .github/workflows/deploy-gh-pages.yml (lines 9-12, job build, job deploy)
.github/workflows/deploy-gh-pages.yml declares its token scopes once, at the workflow level:
permissions:
contents: read
pages: write
id-token: write
Workflow-level permissions: apply to every job. There are two jobs, and only one of them needs those scopes:
| job |
what it runs |
what it actually needs |
build |
npm ci, npm run test:unit, the four validators, npm run build:production, actions/configure-pages, actions/upload-pages-artifact |
contents: read + pages: read |
deploy |
actions/deploy-pages only — no checkout, no npm |
pages: write + id-token: write |
So the build job — the one that installs and executes several hundred third-party npm packages and renders imported third-party architecture content — runs holding both id-token: write and pages: write, neither of which any step in it reads.
actions/configure-pages is pinned here at 983d7736 (v5.0.0) and is invoked without the enablement input, which defaults to false; in that mode it only reads the Pages API, so pages: read is sufficient for the build job. (If enablement: true were ever set, the action documents that GITHUB_TOKEN cannot satisfy it at all, so this does not become a reason to keep pages: write.)
Impact
id-token: write is not an inert flag. When a job is granted it, the runner injects ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN into that job's environment, and any process in the job — including an npm postinstall script from any transitive dependency, or anything reachable from the imported-content build path — can read those two variables and mint a signed GitHub OIDC token asserting repo:cncf/endusers. That token is a bearer credential against every cloud role, registry, or workload-identity trust policy anywhere that trusts this repository's OIDC subject. Nothing in the build job needs it, so today that credential exists only as attacker reward.
pages: write in the same job is the second half: a compromised build step can call the Pages API directly and publish arbitrary content to the live site, without going through upload-pages-artifact → deploy-pages at all, and without tripping the github-pages environment protection that gates the deploy job. Scoping pages: write to the deploy job is what makes that environment gate meaningful.
This is the standard hardening actions/deploy-pages is designed for — the split-job pattern in its own README exists precisely so the build job never holds the deployment credential. It is also a pattern this repository already uses elsewhere: .github/workflows/pdf.yml declares permissions: at the job level, not the workflow level.
Related but distinct, already tracked, and deliberately not part of this issue:
Recommendation
Move the grants to the jobs that use them. Replace the workflow-level block at lines 9-12 with an empty default, and add a permissions: block to each job.
Replace:
permissions:
contents: read
pages: write
id-token: write
with:
Then in the build job, add permissions: immediately after runs-on: ubuntu-latest:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
and in the deploy job, add permissions: immediately after needs: build:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
permissions:
pages: write
id-token: write
steps:
No step, action pin, env: value, or concurrency: setting changes. actions/checkout keeps contents: read; actions/upload-pages-artifact needs no GITHUB_TOKEN scope (it uses the artifact runtime token); actions/deploy-pages keeps exactly the two scopes it requires.
Verification
After the change:
This change is compatible with the token-permission assertions in open PR #301 (tests/ci-supply-chain.test.mjs): that suite requires every workflow to declare permissions: at workflow or job level and forbids write-all, and the layout above satisfies both.
Why this is an issue and not a pull request
The fix lives entirely in .github/workflows/. This agent's token is minted at the contributor tier, which does not carry the GitHub Actions workflows permission, so a push touching that directory is rejected server-side. This needs a human, or an agent whose token carries the workflows permission, to land — it is not a judgement call that the change is unsuitable for a PR. The replacement text above is complete and can be applied as-is.
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 00b44df
— hive: agent=sec-check backend=copilot model=claude-opus-5
Security Finding
Severity: high
Type: permission-issue (CI least-privilege / supply-chain blast radius)
File:
.github/workflows/deploy-gh-pages.yml(lines 9-12, jobbuild, jobdeploy).github/workflows/deploy-gh-pages.ymldeclares its token scopes once, at the workflow level:Workflow-level
permissions:apply to every job. There are two jobs, and only one of them needs those scopes:buildnpm ci,npm run test:unit, the four validators,npm run build:production,actions/configure-pages,actions/upload-pages-artifactcontents: read+pages: readdeployactions/deploy-pagesonly — no checkout, no npmpages: write+id-token: writeSo the
buildjob — the one that installs and executes several hundred third-party npm packages and renders imported third-party architecture content — runs holding bothid-token: writeandpages: write, neither of which any step in it reads.actions/configure-pagesis pinned here at983d7736(v5.0.0) and is invoked without theenablementinput, which defaults tofalse; in that mode it only reads the Pages API, sopages: readis sufficient for thebuildjob. (Ifenablement: truewere ever set, the action documents thatGITHUB_TOKENcannot satisfy it at all, so this does not become a reason to keeppages: write.)Impact
id-token: writeis not an inert flag. When a job is granted it, the runner injectsACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENinto that job's environment, and any process in the job — including an npmpostinstallscript from any transitive dependency, or anything reachable from the imported-content build path — can read those two variables and mint a signed GitHub OIDC token assertingrepo:cncf/endusers. That token is a bearer credential against every cloud role, registry, or workload-identity trust policy anywhere that trusts this repository's OIDC subject. Nothing in thebuildjob needs it, so today that credential exists only as attacker reward.pages: writein the same job is the second half: a compromised build step can call the Pages API directly and publish arbitrary content to the live site, without going throughupload-pages-artifact→deploy-pagesat all, and without tripping thegithub-pagesenvironment protection that gates thedeployjob. Scopingpages: writeto thedeployjob is what makes that environment gate meaningful.This is the standard hardening
actions/deploy-pagesis designed for — the split-job pattern in its own README exists precisely so the build job never holds the deployment credential. It is also a pattern this repository already uses elsewhere:.github/workflows/pdf.ymldeclarespermissions:at the job level, not the workflow level.Related but distinct, already tracked, and deliberately not part of this issue:
actions/checkoutin this and three other workflows omitspersist-credentials: false. That is about a different credential (theGITHUB_TOKENwritten into.git/config), and fixing it does not addressid-token/pagesscope.Recommendation
Move the grants to the jobs that use them. Replace the workflow-level block at lines 9-12 with an empty default, and add a
permissions:block to each job.Replace:
with:
Then in the
buildjob, addpermissions:immediately afterruns-on: ubuntu-latest:and in the
deployjob, addpermissions:immediately afterneeds: build:No step, action pin,
env:value, orconcurrency:setting changes.actions/checkoutkeepscontents: read;actions/upload-pages-artifactneeds noGITHUB_TOKENscope (it uses the artifact runtime token);actions/deploy-pageskeeps exactly the two scopes it requires.Verification
After the change:
buildcompletes: checkout,npm ci,npm run test:unit, all four validators,npm run build:production,configure-pages,upload-pages-artifactall succeed undercontents: read+pages: read.deploycompletes and the site publishes, confirmingpages: write+id-token: writeon that job is sufficient.buildjob log shows no OIDC token request env vars in scope.This change is compatible with the token-permission assertions in open PR #301 (
tests/ci-supply-chain.test.mjs): that suite requires every workflow to declarepermissions:at workflow or job level and forbidswrite-all, and the layout above satisfies both.Why this is an issue and not a pull request
The fix lives entirely in
.github/workflows/. This agent's token is minted at thecontributortier, which does not carry the GitHub Actionsworkflowspermission, so a push touching that directory is rejected server-side. This needs a human, or an agent whose token carries theworkflowspermission, to land — it is not a judgement call that the change is unsuitable for a PR. The replacement text above is complete and can be applied as-is.Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent:
security| Instance:hosted-available-lke648397-260827-5n31| SHA:00b44df— hive: agent=sec-check backend=copilot model=claude-opus-5