Skip to main content

hardening-github-actions

Writes and reviews GitHub Actions workflows with security hardening. Use when creating, modifying, or reviewing .github/workflows/*.yml or .github/actions/*/action.yml files. Covers shell injection prevention, trust gating for fork PRs, action pinning, and secrets hygiene.

Informações da origem

Repositório
input-output-hk/capkgs
Última atividade na origem
16 de junho de 2026 às 16:38
Idioma detectado do SKILL.md
inglês
Estrelas
7
Forks
3

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
hardening-github-actions
description
Writes and reviews GitHub Actions workflows with security hardening. Use when creating, modifying, or reviewing .github/workflows/*.yml or .github/actions/*/action.yml files. Covers shell injection prevention, trust gating for fork PRs, action pinning, and secrets hygiene.
# Hardening GitHub Actions This guidance assumes a **public repository** threat model where untrusted users can open fork PRs. The fork trust-gating sections are primarily relevant in that context. Shell injection prevention, action pinning, credential hygiene, and checkout persistence apply to all repos. ## Shell injection prevention **Never interpolate `${{ }}` expressions directly into `run:` blocks.** GitHub expression substitution happens *before* the shell parses the command. Attacker-controlled values (branch names, PR titles, input fields) can inject arbitrary shell commands. Unsafe values include anything derived from: - `github.event.pull_request.head.ref` (fork branch names allow shell metacharacters) - `github.event.pull_request.title` / `.body` - `github.event.inputs.*` (free-form text) - `github.event.comment.body` - `github.event.pull_request.head.repo.full_name` (fork repo names) ### Fix: use intermediate environment variables Pass untrusted expressions via `env:` on the step, then reference them as quoted shell variables. Environment variables are set at runtime and are not subject to shell expansion. ```yaml # BAD - shell injection via branch name - run: git fetch origin ${{ github.event.pull_request.head.ref }} # GOOD - safe via env - env: HEAD_REF: ${{ github.event.pull_request.head.ref }} run: git fetch origin "$HEAD_REF" ``` This applies to **all** `${{ }}` references in `run:` blocks that touch event data or action inputs. Expressions used only in `with:`, `if:`, or `env:` values (not shell) are fine. Ref: https://docs.github.com/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable ### Safe contexts (no fix needed) - `github.actor` (alphanumeric + hyphens only) - `github.repository` (org/repo, restricted charset) - `github.sha`, `github.run_id`, `github.run_number` (hex/numeric) - Values used only in `if:`, `with:`, `run-name:`, `concurrency.group:`, or other non-shell YAML fields ## Trust gating for fork PRs Fork PRs on `pull_request` triggers get a read-only `GITHUB_TOKEN` and empty `secrets.*` (platform-enforced). But composite actions, checkout steps, and shell commands still execute. Structure workflows to avoid running untrusted code with privileges. ### Preferred: two-job structure ```yaml jobs: detect: runs-on: ubuntu-latest outputs: is_trusted: ${{ steps.check.outputs.is_trusted }} steps: - uses: actions/checkout@<pinned-sha> with: ref: ${{ github.event.pull_request.base.sha || github.sha }} - id: check run: # ... determine trust level build: needs: detect if: needs.detect.outputs.is_trusted == 'true' runs-on: ubuntu-latest steps: # ... privileged steps with secrets ``` Key properties: - **Job 1 checks out trusted code only** (`base.sha` or `github.sha`, never the fork's HEAD) - **Job 2 never spawns for untrusted forks** -- composite actions, checkout-merge, and all secret-bearing steps are unreachable - Step-level `if:` guards are fragile (earlier steps still execute); job-level `if:` is definitive ### Avoid: step-level early exit A step like `if: is_trusted == 'false'` then `exit` leaves all preceding steps exposed to injection. Use this only as a temporary measure. ### Why `pull_request` not `pull_request_target` Use `pull_request` triggers, not `pull_request_target`. With `pull_request`, GitHub runs the workflow from the **base branch** -- fork PRs cannot modify the workflow or composite actions that execute. With `pull_request_target`, the workflow runs with write token and secrets but can be tricked into checking out and executing fork code. If `pull_request_target` is ever needed, it must never check out PR code. ### Detect-job must check out base code only The detect/gate job must use `ref: ${{ github.event.pull_request.base.sha || github.sha }}` to ensure it only runs trusted composite actions from the base branch. If this were changed to check out the PR head, a fork could replace the detect action itself. ### `workflow_dispatch` trust assumptions Manual dispatch with a PR number is treated as trusted because only maintainers/admins can trigger `workflow_dispatch`. Ensure repository settings continue to restrict dispatch permissions -- if collaborators with lower access levels gain `actions: write`, they could dispatch against a fork PR and run its code with secrets. ## Action pinning Pin third-party actions to full commit SHAs, not mutable tags. Tags like `v4` can be force-pushed. ```yaml # BAD - uses: actions/checkout@v4 # GOOD - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 ``` Look up SHAs with: `git ls-remote --tags https://github.com/<owner>/<repo>.git <tag>` Keep the tag as a trailing comment for readability. ## Input validation Validate free-form `workflow_dispatch` inputs before use: ```yaml - env: PR_NUMBER: ${{ github.event.inputs.pr_number }} run: | if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then echo "::error::pr_number must be numeric" exit 1 fi ``` ## Checkout credential persistence Set `persist-credentials: false` on `actions/checkout` unless the job needs to `git push`. The default (`true`) writes the `GITHUB_TOKEN` into `.git/config`, making it readable by every subsequent step -- including third-party actions and build tools. With `contents: write` permission, a compromised step could push to the repository. ```yaml - uses: actions/checkout@<pinned-sha> with: persist-credentials: false ``` If the job must push, keep `persist-credentials: true` (the default) and ensure only trusted code reaches that job via the two-job trust gate. ## Secrets hygiene in `run:` blocks Pass secrets through `env:` rather than inline `${{ secrets.* }}` in shell: ```yaml # BAD - secret in shell substitution, visible in logs on syntax error - run: echo "${{ secrets.KEY }}" > keyfile # GOOD - env: KEY: ${{ secrets.KEY }} run: echo "$KEY" > keyfile ``` ## `GITHUB_OUTPUT` injection When writing to `$GITHUB_OUTPUT` with `echo "key=$value"`, an attacker who controls `value` can inject newlines to set arbitrary output keys. Use the multiline delimiter format when the value could contain newlines (e.g., PR titles, commit messages, comment bodies): ```bash { echo "title<<EOF" echo "$PR_TITLE" echo "EOF" } >> "$GITHUB_OUTPUT" ``` Values with restricted charsets (git branch names, numeric IDs, booleans computed from restricted inputs) are safe with the simple `echo "key=$value"` format. ## `workflow_run` artifact poisoning If adding `workflow_run` triggers: a fork PR can upload artifacts via `pull_request`, and a `workflow_run` job (which has secrets) may then download and process them. Never execute or trust artifact contents from untrusted runs without validation. ## Review checklist When reviewing or writing a workflow: 1. Grep for `${{ ` inside `run:` blocks -- each one is a potential injection point 2. For each match, determine if the value is attacker-controlled; if so, move to `env:` 3. Verify fork PRs cannot reach secret-bearing steps (prefer two-job gate) 4. Confirm third-party actions are SHA-pinned 5. Validate any free-form `workflow_dispatch` inputs 6. Check that `pull_request_target` is not used (or if it is, that it never checks out PR code) 7. Verify no `accept-flake-config = true` or `--accept-flake-config` in Nix steps (prevents flake nixConfig escape) 8. Verify `persist-credentials: false` on all `actions/checkout` steps 9. Verify `workflow_dispatch` permissions remain restricted to maintainers/admins
Ver no GitHub