| name | harden-github-actions |
| description | Harden GitHub Actions CI/CD workflows for supply-chain security — SHA-pin actions, least-privilege token permissions, verified toolchain installs, OpenSSF Scorecard, and SLSA provenance. Use when adding or auditing GitHub Actions workflows, before making a repository public, when a supply-chain review flags CI gaps, or when standardizing CI hardening across GitHub projects. GitHub-specific by design — GitLab CI and Forgejo Actions are out of scope. |
| metadata | {"author":"Georges Martin <jrjsmrtn@gmail.com>","version":"0.1.25"} |
| license | MIT |
Harden GitHub Actions
Harden GitHub Actions CI/CD workflows against supply-chain attack: pin what runs, minimise what it can do, and verify what it fetches.
GitHub-specific by design. Unlike most orchestration skills, this one is deliberately bound to
one forge. The hardening controls below are not portable concepts wearing GitHub syntax — they are
properties of the GitHub Actions execution model itself: third-party actions resolved by mutable
git ref, an ambient GITHUB_TOKEN with repository-wide default scopes, and OIDC-backed SLSA
provenance. It has a sibling, harden-gitlab-ci, which is not a translation of this skill:
GitLab's risks sit in different places (include:/CI-Catalog components, and a CI_JOB_TOKEN that
defaults to own-project-only — so there the work is keeping it scoped, the opposite posture from
here). Forgejo Actions is Actions-compatible in shape but resolves actions against its instance's
configured registry, so pinning guidance does not transfer unchanged (roadmap H9).
When to Use
- When adding GitHub Actions workflows to a project (after
setup-pre-commit)
- When auditing existing
.github/workflows/ before making a repository public
- When a supply-chain review (or the
supply-chain skill) flags CI hardening gaps
- When standardising CI hardening across GitHub projects
Not for: GitLab CI — use harden-gitlab-ci. Not for Forgejo/Gitea Actions — the controls do not carry over unchanged. Say so rather than approximating.
This skill hardens CI workflows. validate-quality-config only reads CI to confirm parity with local hooks — it does not check pinning, permissions, or provenance. The two are complementary.
Required Inputs
- Workflow files — the project's
.github/workflows/*.yml
- Release model — does the project publish artifacts/images/packages (needs provenance + signing), or is it internal-only?
- Repository visibility — public or private (affects Scorecard
publish_results and OSSF publishing)
The Threat
A CI job runs third-party code (actions, installed tools) with a GITHUB_TOKEN and often OIDC. A mutable action tag (@v4) can be repointed to malicious code; an over-privileged token can push commits, publish packages, or exfiltrate secrets; an unverified curl | sh install can be swapped upstream. Hardening closes all three: pin, least-privilege, verify.
Workflow
Step 1: Inventory and triage
Find every workflow and the hardening gaps in it:
ls .github/workflows/
grep -rnE 'uses:.*@(v[0-9]|main|master|latest)' .github/workflows/
for f in .github/workflows/*.yml; do grep -q '^permissions:' "$f" || echo "no permissions: $f"; done
grep -rnE 'version:\s*latest|@latest' .github/workflows/
Aim for a clean 3-way split: ci.yml (lint/test/build on push + PR), scorecard.yml (OpenSSF Scorecard on a schedule), and — only if the project ships artifacts — release.yml (SBOM + SLSA provenance + signing).
Step 2: SHA-pin every action
Pin every uses: to a full 40-character commit SHA with a trailing # vX.Y.Z comment for readability. Never rely on a mutable tag.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92
- uses: actions/checkout@v4
Resolve a tag to its commit SHA:
gh api repos/actions/checkout/commits/v4.2.2 --jq '.sha'
Keep pins current automatically with a Dependabot github-actions update block (see bootstrap-project / the dependency-update config). Dependabot preserves the # vX.Y.Z comment when it bumps a SHA.
The one sanctioned exception: reusable workflows that verify their own release tag — notably slsa-framework/slsa-github-generator — must be referenced by semantic version tag, not SHA (see Step 7). Document the exception in an ADR and in a comment on the line.
Step 3: Least-privilege token permissions
Set a read-only default at the top of every workflow, then elevate per job only where a job genuinely needs to write.
permissions:
contents: read
Elevate narrowly, in the specific job:
jobs:
analysis:
permissions:
security-events: write
id-token: write
contents: read
provenance:
permissions:
actions: read
id-token: write
contents: write
Rule of thumb: default contents: read; add id-token: write only for OIDC signing/publish; add contents: write only for jobs that create releases/tags; add packages: write only for registry publish; add security-events: write only for SARIF upload.
Step 4: Harden the checkout
On any workflow that runs with elevated permissions or handles releases (Scorecard, release), stop the checkout from leaving credentials on disk:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
Step 5: Verify what you install
Every tool a job installs is attack surface. In order of preference:
- SHA-pinned setup action (best) — toolchains via pinned actions:
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
with: { distribution: temurin, java-version: "21" }
- uses: xu-cheng/texlive-action@22c04326a5d855880f9d39bb955138bf11c6df80
- Pinned version + checksum verification for a downloaded binary:
- run: |
curl -fsSL -o tool.tar.gz "https://example.com/tool/v1.2.3/tool.tar.gz"
echo "abc123... tool.tar.gz" | sha256sum -c -
tar xzf tool.tar.gz
- Pinned package version for distro/language packages.
Anti-patterns to eliminate (all seen in real workflows):
- run: luarocks install luacheck
- run: apt-get install -y -qq pandoc
with: { version: latest }
- run: curl -fsSL https://x/install.sh | sh
Digest-pin any container image referenced in a run/service step (image@sha256:…, not :latest); base-image and artifact-checksum hardening for the images themselves belongs to setup-container-security.
Step 6: OpenSSF Scorecard
Add a Scorecard workflow to continuously grade the repo's supply-chain posture. Canonical scorecard.yml:
name: Scorecard
on:
branch_protection_rule:
schedule:
- cron: "26 7 * * 1"
workflow_dispatch:
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357
with:
sarif_file: results.sarif
On a private repo set publish_results: false and flip it to true at public release.
Step 7: Release provenance (if the project ships artifacts)
For projects that publish artifacts/images/packages, attach SLSA build provenance at release. Hash the build outputs, then hand off to the generator — referenced by tag (the sanctioned exception to Step 2):
provenance:
needs: [build]
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: ${{ needs.build.outputs.hashes }}
upload-assets: true
Provenance, SBOM (syft), and keyless signing (cosign) at release time are owned by wrapup-sprint's release step — this skill wires the workflow permissions and the tag-pin exception that make them safe.
Step 8: Remaining hardening
- Dependency review on PRs:
dependency-review:
if: github.event_name == 'pull_request'
steps:
- uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019
- Cancel superseded runs — add a
concurrency block per workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
- Bound every job with
timeout-minutes: (a runaway job holds a token).
- Short artifact retention:
retention-days: 5 on upload-artifact.
- Optional egress control:
step-security/harden-runner (SHA-pinned) to audit/block network egress.
- CodeQL code scanning (
codeql-action/init + analyze) for compiled/scripted languages — note: uploading a Scorecard SARIF is not code scanning.
Outputs
This skill produces/updates:
Validation
grep -rnE 'uses:.*@(v[0-9]|main|master|latest)' .github/workflows/ | grep -v slsa-github-generator
for f in .github/workflows/*.yml; do grep -q '^permissions:' "$f" || echo "MISSING permissions: $f"; done
grep -rnE 'curl.*\|\s*(sh|bash)' .github/workflows/
Optionally run actionlint for workflow-syntax validation, and check the repo's OpenSSF Scorecard once the workflow has run.
Related Skills
harden-gitlab-ci — the sibling for GitLab CI. Same intent, different execution model; neither is a translation of the other (GitLab's risks sit in include:/CI-Catalog components and the CI_JOB_TOKEN allowlist)
setup-pre-commit — local quality gates that CI mirrors
validate-quality-config — verifies CI/local parity (reads CI; does not harden it)
wrapup-sprint — release-time SBOM, SLSA provenance, and signing
setup-container-security — base-image digest pinning and image scanning
setup-adrs — record the SHA-pin policy and the SLSA-generator exception