Skip to content

Harden CI: Pin GitHub Actions to commit SHAs and tighten workflow token permissions - #76

Open
sayotte wants to merge 7 commits into
dnut:masterfrom
sayotte:bug/harden-ci-configs
Open

sayotte wants to merge 7 commits into
dnut:masterfrom
sayotte:bug/harden-ci-configs

Conversation

@sayotte

@sayotte sayotte commented Nov 11, 2026

Copy link
Copy Markdown

What

Apply security hygiene to CI configs for known-exploitable attack vectors.

Why

As a user of this extension I'm concerned about supply-chain vulnerability. I looked at the repo's CI especially, because of the recent GitHub Actions-based attacks:

  • tj-actions/changed-files (March 2025): attackers exploited a CI misconfiguration upstream of tj-actions (a widely used Action: 23,000+ dependent repositories), pivoted to tj-actions, then pointed nearly every version tag at a malicious commit that dumped runner memory, including CI secrets, into workflow logs, where they were readable in public repositories. The compromise traced back to a stolen token from the related reviewdog/action-setup compromise (CVE-2025-30154). Downstream CI workflows pinned to a commit SHA were unaffected. (CVE-2025-30066) NVD · StepSecurity analysis · CISA alert
  • Trivy / TeamPCP tag hijack (March 2026): attackers exploited misconfigured CI, and then force-pushed malicious commits to 76 of 77 tags in aquasecurity/trivy-action and all 7 tags in aquasecurity/setup-trivy, stealing CI secrets from every workflow that referenced them by tag (CVE-2026-33634). Legit Security writeup · CSA research note
  • prt-scan (March 2026): 500+ malicious pull requests abusing pull_request_target workflows to exfiltrate secrets and cloud credentials. CSA research note
  • actions-cool/issues-helper tag hijack (May 2026): tags redirected to an imposter commit that exfiltrated CI credentials. The Hacker News
  • Megalodon (May 2026): malicious workflows injected into more than 5,500 repositories via automated commits. SecurityWeek
  • GhostAction waves (2026): credential-stealing workflow files pushed to tens of thousands of repositories. Infosecurity Magazine · GBHackers

I found that all workflows in this repo currently reference Actions by mutable tags (@v4, @v2, …). A tag can be moved by the Action's maintainer — or by anyone who compromises their account — and every workflow using it silently runs the new code on its next trigger. Here that matters most for release.yml, which runs with contents: write and publishes the .vsix users install, and docs.yml's deploy job (pages: write, id-token: write).

Changes

  • Pin every Action to a full 40-char commit SHA, with the release as a trailing comment (@<sha> # v4.4.0).
    • The given SHAs are what the existing tags resolve to, see below.
  • main.yml: add permissions: contents: read. It previously inherited the repo default token permissions (read/write on older repos), though the job never writes anything.
  • release.yml: persist-credentials: false on checkout. Otherwise the contents: write token sits in .git/config while npm install runs package lifecycle scripts. Nothing later needs it: the tag fetch is anonymous on a public repo, and action-gh-release receives the token via its own input.
  • release.yml: quote "${GITHUB_REF_NAME#v}" to clear a shellcheck SC2086 notice
    • This is good shell hygiene so it's not pointless, but the SC2086 warning isn't actually exploitable given the tag-filter already present in release.yml-- this commit can be dropped if you don't want it.
Action Was Now
actions/checkout v4 / v7.0.1 11d5960… v4.4.0 / 3d3c42e… v7.0.1
cachix/install-nix-action v27 / 31.11.3 ba0dd84… v27 / 75c75c3… v31.11.3
actions/configure-pages v5 983d773… v5.0.0
actions/upload-pages-artifact v3 56afc60… v3.0.1
actions/deploy-pages v4 d6db901… v4.0.5
softprops/action-gh-release v2 3bb1273… v2.6.2

Keeping pins current (suggestion, not included in this PR)

Pins don't auto-update, so security fixes upstream won't arrive on their own.

Dependabot handles SHA-pinned Actions natively; when upstream moves it submits a reviewable PR that bumps the SHA and the # vX.Y.Z comment. A minimal .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Not addressed here

  • actions/upload-pages-artifact internally uses actions/upload-artifact@v4 by tag (outside this repo's control).
  • ./do uses npm install rather than npm ci; switching would further reduce build-time supply-chain exposure.
  • Consider a tag protection rule for v*, since pushing one triggers a write-scoped release.

sayotte and others added 7 commits October 28, 2026 13:50
Git tags are mutable: whoever controls the upstream repository (or an
attacker who compromises it) can move `v4` or even `v7.0.1` to point at
different code, and every workflow referencing the tag will silently run
it on the next trigger. This has happened in the wild (e.g. the
tj-actions/changed-files compromise, CVE-2025-30066).

Pinning to the full 40-character commit SHA makes the reference
immutable. The SHAs below are the commits the existing tags resolve to
today, so CI behavior is unchanged; the trailing comment records the
exact release for readability and for update tooling such as Dependabot.

- actions/checkout@v4     -> 11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- actions/checkout@v7.0.1 -> 3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same rationale as the actions/checkout pin: a mutable tag lets the
upstream (or anyone who compromises it) change the code CI executes
without any change in this repository. install-nix-action is especially
sensitive because it runs with the job's token and installs the Nix
toolchain that every subsequent build step executes inside.

The SHAs are the commits the current tags resolve to, so behavior is
unchanged:

- cachix/install-nix-action@v27     -> ba0dd844c9180cbf77aa72a116d6fbc515d0e87b # v27
- cachix/install-nix-action@31.11.3 -> 75c75c31ebece974e48ebc36b4ad125bd97bd97b # v31.11.3

(Upstream publishes `v27` only as a major tag with no v27.x.y release
tag on that commit, so the comment keeps `v27`. `31.11.3` and `v31.11.3`
are both upstream tags on the same commit; the comment uses the
v-prefixed form for consistency with the other pins.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same rationale as the other pins. deploy-pages is the most sensitive of
the three: its job holds `pages: write` and `id-token: write`, so code
substituted behind a moved tag could deface the docs site or mint OIDC
tokens for this repository.

The SHAs are the commits the current tags resolve to, so behavior is
unchanged:

- actions/configure-pages@v5        -> 983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- actions/upload-pages-artifact@v3  -> 56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
- actions/deploy-pages@v4           -> d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

Note: upload-pages-artifact is a composite action that internally calls
actions/upload-artifact by tag, which this repository cannot pin; see
the PR description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This is the highest-value pin in the repository. The release workflow
runs with `contents: write` and this action is what publishes the
.vsix that users install, so a moved `v2` tag would let a third party
replace or tamper with release assets or rewrite release notes. Unlike
the actions/* pins it is maintained by an individual, which widens the
set of accounts whose compromise reaches this repo.

The SHA is the commit the current `v2` tag resolves to (a self-contained
node20 action with a bundled dist/index.js and no nested `uses:`), so
behavior is unchanged:

- softprops/action-gh-release@v2 -> 3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.yml had no `permissions:` block, so its GITHUB_TOKEN inherits the
repository/organization default. For repositories created before
February 2023 (and any repo where the setting was never changed) that
default is read/write across most scopes, including contents, packages,
actions and pull-requests.

The job only checks out code, builds, tests and packages; it never
writes to the repository or the API. Declaring `contents: read`
explicitly makes the workflow least-privilege regardless of the repo
setting, so a compromised build dependency (npm packages are installed
with lifecycle scripts during `./do test`/`./do package`) cannot use the
token to push commits, move tags, or tamper with releases.

Fork pull requests already receive a read-only token, so this changes
nothing for them; it only narrows `push` runs to what they actually use.
This matches the explicit blocks already present in docs.yml and
release.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
By default actions/checkout writes the job's GITHUB_TOKEN into
.git/config so later steps can run authenticated git commands. In the
release job that token has `contents: write`, and the steps that follow
run `nix develop -c ./do package`, which executes `npm install` (with
package lifecycle scripts) and `npx vsce`. Any compromised package in
that dependency tree could read the token from .git/config and push
commits, move tags, or alter releases.

Nothing after checkout needs the persisted credential:
- `git fetch --tags --force origin` reads from a public repository and
  works anonymously.
- `./do package` / `./do release-notes` make no authenticated git or API
  calls.
- softprops/action-gh-release receives the token through its own
  `token` input (default `github.token`), not from git config.

Setting `persist-credentials: false` keeps the token available only to
the action that actually needs it. Behavior of the release is otherwise
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
actionlint/shellcheck flags SC2086 on the unquoted ${GITHUB_REF_NAME#v}
passed to `./do release-notes`. It is not exploitable today, because
the workflow's tag filter (v[0-9]+.[0-9]+.[0-9]+) means the value can
only contain digits and dots, but quoting it is the correct form, keeps
the workflow lint-clean, and stays safe if the trigger filter is ever
loosened. No behavior change for any tag the workflow currently accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dnut

dnut commented Nov 13, 2026

Copy link
Copy Markdown
Owner

Thanks for your contribution. I agree with the idea behind this change, I just need to take a close look at all the commits before moving forward. FYI, Github actions are not currently used for releasing the extension to the marketplace.

This branch has not been deployed

No deployments
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