From project-orchestration-skills
Hardens GitHub Actions CI/CD workflows for supply-chain security: SHA-pin actions, least-privilege tokens, verified toolchain installs, OpenSSF Scorecard, SLSA provenance. Use when adding/auditing CI or before making a repo public.
How this skill is triggered — by the user, by Claude, or both
Slash command
/project-orchestration-skills:harden-ci-workflowsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Harden GitHub Actions CI/CD workflows against supply-chain attack: pin what runs, minimise what it can do, and verify what it fetches.
Harden GitHub Actions CI/CD workflows against supply-chain attack: pin what runs, minimise what it can do, and verify what it fetches.
setup-pre-commit).github/workflows/ before making a repository publicsupply-chain skill) flags CI hardening gapsThis 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.
.github/workflows/*.ymlpublish_results and OSSF publishing)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.
Find every workflow and the hardening gaps in it:
ls .github/workflows/
# Mutable action refs (should be zero — all must be SHA-pinned):
grep -rnE 'uses:.*@(v[0-9]|main|master|latest)' .github/workflows/
# Workflows missing a top-level permissions block:
for f in .github/workflows/*.yml; do grep -q '^permissions:' "$f" || echo "no permissions: $f"; done
# Floating tool versions:
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).
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.
# Good — immutable, human-readable, Dependabot-updatable
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# Bad — mutable tag, can be repointed upstream
- 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.
Set a read-only default at the top of every workflow, then elevate per job only where a job genuinely needs to write.
# top of the workflow
permissions:
contents: read
Elevate narrowly, in the specific job:
jobs:
# OpenSSF Scorecard
analysis:
permissions:
security-events: write # upload the SARIF result
id-token: write # publish results to the OSSF API
contents: read
# Release provenance / publish
provenance:
permissions:
actions: read # read the workflow path
id-token: write # mint the OIDC token for signing
contents: write # attach provenance to the release
# packages: write # add only if publishing to GHCR / a registry
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.
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 # v7.0.0
with:
persist-credentials: false
Every tool a job installs is attack surface. In order of preference:
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: { distribution: temurin, java-version: "21" }
- uses: xu-cheng/texlive-action@22c04326a5d855880f9d39bb955138bf11c6df80 # v3
- 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
Anti-patterns to eliminate (all seen in real workflows):
- run: luarocks install luacheck # no version pin
- run: apt-get install -y -qq pandoc # unversioned distro package
with: { version: latest } # floating action release
- run: curl -fsSL https://x/install.sh | sh # unverified remote script — never
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.
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" # weekly, Mondays
workflow_dispatch:
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write # upload the SARIF result
id-token: write # publish results to the OSSF API
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true # requires a PUBLIC repo; keep false while private
- uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
with:
sarif_file: results.sarif
On a private repo set publish_results: false and flip it to true at public release.
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
# slsa-github-generator MUST be tag-pinned (it verifies its own release tag) — the
# documented exception to the SHA-pin rule; record it in an ADR.
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
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.
dependency-review:
if: github.event_name == 'pull_request'
steps:
- uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
concurrency block per workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
timeout-minutes: (a runaway job holds a token).retention-days: 5 on upload-artifact.step-security/harden-runner (SHA-pinned) to audit/block network egress.codeql-action/init + analyze) for compiled/scripted languages — note: uploading a Scorecard SARIF is not code scanning.This skill produces/updates:
uses: in .github/workflows/* SHA-pinned with # vX.Y.Z comments (grep for @v[0-9]/@main returns nothing except the SLSA generator)permissions: (read-only default) on every workflow, with narrow per-job elevationpersist-credentials: false on release/Scorecard checkoutscurl | sh, no version: latest.github/workflows/scorecard.yml.github/workflows/release.yml provenance job (if the project ships artifacts)concurrency + timeout-minutes on workflows/jobs# No mutable action refs remain (SLSA generator is the only allowed tag ref):
grep -rnE 'uses:.*@(v[0-9]|main|master|latest)' .github/workflows/ | grep -v slsa-github-generator
# Every workflow declares permissions:
for f in .github/workflows/*.yml; do grep -q '^permissions:' "$f" || echo "MISSING permissions: $f"; done
# No unverified remote installs:
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.
setup-pre-commit — local quality gates that CI mirrorsvalidate-quality-config — verifies CI/local parity (reads CI; does not harden it)wrapup-sprint — release-time SBOM, SLSA provenance, and signingsetup-container-security — base-image digest pinning and image scanningsetup-adrs — record the SHA-pin policy and the SLSA-generator exceptionnpx claudepluginhub jrjsmrtn/jrjsmrtn-skills --plugin project-orchestration-skillsCreates bite-sized, testable implementation plans from specs or requirements, with file structure and task decomposition. Activates before coding multi-step tasks.