Skip to content

ci: read-only top-level workflow tokens; never re-attempt research-v1 (Scorecard #173-#178) - #197

Merged
ErenAri merged 4 commits into
mainfrom
security/workflow-token-permissions
Oct 14, 2026
Merged

ErenAri merged 4 commits into
mainfrom
security/workflow-token-permissions

Conversation

@ErenAri

@ErenAri ErenAri commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

DRAFT — security remediation. Not authorized to merge; needs operator approval of the exact head SHA.

Head: 1c6926b2651bc21551658fa17c13b939a7d1da6b (4 commits on main 802d515; the 4th is a docs-only precision fix)

Alerts

All 6 open code-scanning alerts on main come from OpenSSF Scorecard v5.5.0, rule TokenPermissionsID (high, supply-chain/local), not CodeQL. The Dependabot and secret-scanning queues are empty. No alert touches the production Go boundary enforced by scripts/check-production-code-scanning.sh, which is unchanged.

Alert Finding Root cause
#178 scholarly-release-v1.yml:10 topLevel contents: write Defect: a top-level write grant applies to every job.
#173 / #174 promote-release.yml promote: jobLevel contents / packages write Required grants; "score is 0" only because of #178
#175 release-artifacts.yml stage-draft-release: jobLevel contents write Required; same
#176 release-candidate-canary.yml external-consumers: jobLevel actions write Required; same
#177 rollback-drill.yml rollback: jobLevel packages write Required; same

Does moving #178 to job level close it, or just move it?

It closes the finding. In Scorecard v5.5.0 (checks/evaluation/permissions.go):

  • a top-level contents/packages/actions write drops Token-Permissions to 0;
  • a job-level write is only a warning, and lowers the score only when the same file has a top-level write or no top-level block.

pkg/scorecard/sarif.go emits no results for a check that meets the policy minimum. Below the minimum, every warning becomes an alert stamped with the check score. That is why #178 also turned the five required job-level grants into "score is 0" alerts.

Verified with the official v5.5.0 binary (checksum-verified) in local mode, Token-Permissions, policy minimum 10:

Tree Score SARIF results
main 802d515 0 6: exactly #173–#178 (same files, lines, messages)
this head 10 0

The job-level warnings still appear in --show-details (including the new scholarly-release-v1.yml publish grant), but they produce no alerts. Expected after merge: the next Scorecard upload on main stops reporting all six, so GitHub should mark #173–#178 fixed. That is only confirmed once the post-merge scan has run.

docs/workflow-token-permissions.md documents the five required grants separately: the step that uses each, and its gate. None are dismissed or suppressed.

Fixes

  1. deps: bump the go-deps group across 1 directory with 2 updates #178 (commit cf7a20a). In scholarly-release-v1.yml and kernel-freshness.yml (same root cause, unreported), the top level becomes contents: read and the grants move onto the single job.

    • Each job's effective token is unchanged.
    • New CI guard scripts/check-workflow-permissions.sh requires read-only top levels and a justified allowlist (scripts/workflow-write-permissions.allow, 27 entries) for every job-level write.
    • It rejects write-all, stale entries and duplicate keys, scans .yml/.yaml case-insensitively, and parses with a SafeLoader.
  2. No re-attempt of research-v1 (commits 1daeb15 + acff42c). scholarly-release-v1 runs on every edit to itself, so this PR alone would have re-run the publish job with a contents: write token. Its own refusal step would have stopped it, with a red run. Now a read-only state job (contents/attestations: read, persist-credentials: false) runs scripts/research-v1-release-state.sh first. The write-scoped publish job needs needs.state.outputs.publish == 'true'.

    • publish=true only when neither tag nor release exists and the run is the pinned commit 141c491. That is the original first publication, which can no longer happen.
    • publish=false (green, no write token) only when every check passes:
      • the annotated tag (direct and peeled refs) points to 141c491;
      • the release is non-draft, non-prerelease, with exactly 4 assets;
      • archive-lock.json is byte-identical to the reviewed lock;
      • manifest and payload digests and size match the lock;
      • RELEASE-CHECKSUMS.txt lists exactly the other 3 files and verifies;
      • all 4 assets carry a scholarly-release-v1.yml attestation for 141c491 on refs/heads/main.
    • Anything else fails and is never silently skipped: partial publication (tag-only or release-only), any mismatch, a lookup error, or a missing release seen from a later commit (re-creating it would produce a different research-v1).
    • check-release-consistency.sh now requires this gate (pinned commit, state script, needs: state, exact if:, read-only state job).
    • Live, read-only, against the real release: publish=false from 802d515; fails with a wrong pinned commit.
  3. Frozen-archive catch (commit acff42c).

    • On 1daeb15, the guard and its test lived in scripts/research/. archive-plan.json puts that whole directory into the immutable research-v1 payload, so the rebuilt archive grew from 668 to 670 files and research-archive-v1 ("Build and verify archival payload") correctly failed.
    • Both files now live in scripts/. A local rebuild from the pinned source artifacts again matches the committed archive-lock.json exactly (668 files, manifest 6ed6d38d…, payload 143a8e8a…), and test-archive-v1.sh passes.
    • That workflow only triggers on scripts/research/** and similar paths, and the net PR diff no longer touches them, so it may not re-run on this head. It is not a required check.

Tests

  • Negative:
    • The workflow-permission suite fails with main's workflows.
    • The consistency check rejects the ungated workflow from cf7a20a.
    • Mutation testing of the state guard: 12 of 13 mutations killed. The survivor drops the payload size check, which is equivalent because the SHA-256 binding implies the size.
  • Positive: all suites pass on this head.
    • check-workflow-permissions_test.sh: 20 fixtures, each failing for its stated reason.
    • research-v1-release-state_test.sh: 30 checks, real git remote, stubbed gh.
    • check-release-consistency_test.sh: 5 gate mutations, each rejected for the gate.
  • Local: go vet, go test ./..., go test -race ./..., govulncheck, make check-release-consistency, production-code-scanning_test, runner-pin tests, strict check-canary-pins, make production-tech-check all PASS.
    • Local golangci-lint can't run because the installed binary is built with go1.25, below the repo's 1.26.9. No Go files changed; the CI golangci-lint check covers it.
  • Hosted CI / CodeQL on this head: reported in the checks below.

Independent security review

Two passes, no blockers.

  • Pass 1: found duplicate-key, upper-case-extension and fixture-reason gaps; all fixed.
  • Pass 2 (re-run guard) confirmed:
    • every non-verified path fails closed;
    • gh release view prints exactly release not found for a missing release;
    • nothing but the publish= line reaches $GITHUB_OUTPUT;
    • success() is implied on the publish if:;
    • no release, environment or canary workflow is touched.
  • Pass 2 notes, now fixed: require the direct tag ref, assert the reasons in the gate mutations, add */./ checksum cases.
  • Pass 2 unverified point: whether attestations: read lets gh attestation verify work with GITHUB_TOKEN. Today's promote-release run 38604911768 verified attestations with exactly actions: read, attestations: read. If it were insufficient, the state job fails closed (red), it does not publish.

Remaining risks

  • After merge, the scholarly-release-v1 run on the merge commit should be green: the state job verifies, publish is skipped. If that verification ever fails, the run is red by design and nothing is written.
  • check-release-consistency.sh checks that the state script is invoked, not its contents. Weakening the script relies on review and CI tests.
  • release-candidate-canary.yml (release: v0.3.8 stable metadata and maintenance readiness evidence from rc.2 (DRAFT, NOT READY) #176) and all release, promotion and drill workflows are unchanged, as are environments and protections.

Signed-off-by: ErenAri erenari27@gmail.com

Code-scanning alert #178 (OpenSSF Scorecard Token-Permissions, topLevel):
scholarly-release-v1.yml granted contents, id-token and attestations write
at the workflow top level. A top-level grant applies to every job,
including any job added later, so it is never least privilege.
kernel-freshness.yml had the same defect with issues: write; Scorecard
does not report that one, but it is the same root cause.

Both workflows have exactly one job. The write grants move onto that job
(publish / freshness), and the top level becomes contents: read. Each
job's effective permissions are unchanged; only the default for any
future job is narrowed.

Add scripts/check-workflow-permissions.sh, run in CI by
scripts/check-workflow-permissions_test.sh:
- Every workflow must declare read-only top-level permissions.
- write-all is rejected.
- Every job-level write must be listed, with a justification, in
  scripts/workflow-write-permissions.allow.
- Stale allowlist entries fail, so the list cannot become a blanket
  exemption.

The suite fails against the workflows on main (802d515) and passes
after this change. It covers flow mappings, quoted values, an omitted
block, shorthands, an unlisted job grant and a grant moved to another job.

Signed-off-by: ErenAri <erenari27@gmail.com>
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6cffe969-fa64-4faa-ab4e-cf31f48b445e
📥 Commits

Reviewing files that changed from the base of the PR and between 802d515 and 1c6926b.

📒 Files selected for processing: 11
  • .github/workflows/ci.yml
  • .github/workflows/kernel-freshness.yml
  • .github/workflows/scholarly-release-v1.yml
  • docs/workflow-token-permissions.md
  • scripts/check-release-consistency.sh
  • scripts/check-release-consistency_test.sh
  • scripts/check-workflow-permissions.sh
  • scripts/check-workflow-permissions_test.sh
  • scripts/research-v1-release-state.sh
  • scripts/research-v1-release-state_test.sh
  • scripts/workflow-write-permissions.allow
 ______________________________________
< I read stack traces like tea leaves. >
 --------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

scholarly-release-v1 runs on every push to main that changes its own
file. research-v1 was published once, from 141c491 on 2026-09-19 (run
35456618576). Any later edit, including the permission fix in this PR,
re-ran the publish job with a contents:write token. Its own refusal step
stopped it, but the run went red.

A read-only `state` job (contents/attestations read) now runs
scripts/research/research-v1-release-state.sh before anything else, and
the write-scoped `publish` job runs only on
`needs.state.outputs.publish == 'true'`:

- publish=true only when neither the tag nor the release exists AND the
  run is the pinned commit 141c491. That is the original first
  publication, which can no longer happen.
- publish=false only when the published release is verified intact. The
  annotated tag (direct and peeled refs) peels to 141c491. The release is non-draft and
  non-prerelease with exactly the four assets. archive-lock.json is
  byte-identical to the reviewed lock. The manifest and payload match
  the lock. RELEASE-CHECKSUMS.txt covers exactly the other three files
  and verifies. All four assets carry a scholarly-release-v1.yml
  attestation for 141c491 on refs/heads/main. The run ends green with no
  write token issued.
- Every other state fails closed and never silently skips: a missing
  release run from another commit (which would create a different
  research-v1), a partial publication, a mismatch, or a lookup error.

scripts/check-release-consistency.sh now requires this gate: the pinned
commit, the state script, `needs: state`, the exact `if:` and a
read-only state job. It rejects the ungated workflow from cf7a20a.

Tests:
- scripts/research/test-release-state-v1.sh (real git remote, stubbed
  gh) has 30 checks, each asserting the failure reason.
- Every guard mutation is caught except dropping the payload size check.
  That one is equivalent, since the payload SHA-256 binding implies size.
- Against the live release, the script returns publish=false from
  802d515 and fails for a wrong pinned commit.

docs/workflow-token-permissions.md records the Scorecard v5.5.0 scoring
rules, reproduces alerts #173-#178 locally on 802d515 (0 SARIF results
on this tree, score 10), and documents the five required job-level
write grants with the step that uses each and its gate.

Signed-off-by: ErenAri <erenari27@gmail.com>
@ErenAri ErenAri changed the title ci: keep top-level workflow token permissions read-only (Scorecard #178) ci: read-only top-level workflow tokens; never re-attempt research-v1 (Scorecard #173-#178) Oct 14, 2026
research/archive/v1/archive-plan.json includes all of scripts/research/
in the immutable research-v1 payload. Adding the state guard and its test
there grew the rebuilt archive from 668 to 670 files. The pinned
archive-lock.json then no longer matched, and "Build and verify archival
payload" (research-archive-v1.yml) failed on 1daeb15, as it should.

Move them to scripts/research-v1-release-state.sh and
scripts/research-v1-release-state_test.sh and update every reference:
the scholarly workflow, ci.yml, check-release-consistency.sh and the
docs. A local rebuild from the pinned source artifacts again yields the
committed lock exactly: 668 files, manifest 6ed6d38d, payload 143a8e8a.
test-archive-v1.sh passes.

Signed-off-by: ErenAri <erenari27@gmail.com>
pkg/scorecard/sarif.go (shouldAddLocation) emits a warning as a SARIF
result only when the check is below the policy minimum, details are
enabled, and the warning has a file location; an inconclusive check is
skipped too. Found by independent review of #197.

Signed-off-by: ErenAri <erenari27@gmail.com>
@ErenAri
ErenAri marked this pull request as ready for review October 14, 2026 18:39
Copilot AI balanced review requested due to automatic review settings October 14, 2026 18:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ErenAri
ErenAri merged commit b1a452e into main Oct 14, 2026
12 of 13 checks passed
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