# 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.

- Skill: `input-output-hk/hardening-github-actions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add input-output-hk/hardening-github-actions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/input-output-hk/hardening-github-actions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: input-output-hk (https://skillmd.com/u/input-output-hk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/input-output-hk/hardening-github-actions

---


# 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

