Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions .ai/skills/gha/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
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

Always set `persist-credentials: false` on `actions/checkout`. 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 git push is needed later, configure credentials explicitly for just that step.

## 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
196 changes: 196 additions & 0 deletions .github/workflows/container-image.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
name: Container Image
# Builds the kupo container image from the static musl binary built by
# Hydra, and pushes it to GHCR.

on:
workflow_dispatch:
push:
tags:
- '**'
Comment thread
snarlysodboxer marked this conversation as resolved.

permissions:
contents: read
checks: read
packages: write

jobs:
build-and-push:
name: "Build and push container image"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Comment thread
snarlysodboxer marked this conversation as resolved.
fetch-depth: 0
persist-credentials: false

# Images are versioned after git tags, and only published for tags
# pointing at commits that are part of master.
- name: Check that the workflow runs on a tag
if: github.ref_type != 'tag'
run: |
echo "::error::This workflow must run on a git tag (got $GITHUB_REF); refusing to publish images."
exit 1

# For annotated tags, GITHUB_SHA is the SHA of the tag object itself,
# not of the commit it points at; HEAD after checkout is the commit.
- name: Resolve tag to commit SHA
run: echo "TARGET_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"

- name: Check that the tagged commit is on master
run: |
if ! git merge-base --is-ancestor "$TARGET_SHA" origin/master; then
echo "::error::Tag $GITHUB_REF_NAME points at $TARGET_SHA, which is not on master; refusing to publish images."
exit 1
fi

# The deadline is generous because CI includes a chain synchronization
# that can still be running when the tag is pushed.
- name: Wait for Continuous Integration to pass
env:
GH_TOKEN: ${{ github.token }}
run: |
check_name="integration-tests"
deadline=$((SECONDS + 3600))
while true; do
conclusions=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TARGET_SHA/check-runs?check_name=$check_name" \
--jq '[.check_runs[].conclusion // "pending"] | unique | join(",")')
case "$conclusions" in
success) echo "Continuous Integration passed"; break ;;
""|*pending*) echo "Continuous Integration pending, waiting 30s..." ;;
*) echo "::error::Continuous Integration concluded with: $conclusions"; exit 1 ;;
esac
if [ "$SECONDS" -ge "$deadline" ]; then
echo "::error::Timed out after 60 minutes waiting for Continuous Integration"
exit 1
fi
sleep 30
done

- name: Install Nix
uses: cachix/install-nix-action@b97f05dcb019ddea06450a50ef6203d2fdc19fee # v31
with:
extra_nix_config: |
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=
substituters = https://cache.iog.io/ https://cache.nixos.org/

# Temporary: poll the Nix cache directly until Hydra check-run reporting
# is fixed. Both architectures are built by Hydra; wait for each.
- name: Wait for kupo-musl in Hydra cache
run: |
set -o pipefail
echo "Target SHA: $TARGET_SHA"
flake_ref="git+https://github.com/$GITHUB_REPOSITORY?rev=$TARGET_SHA&submodules=1"
deadline=$((SECONDS + 1800))
for sys in x86_64-linux aarch64-linux; do
if ! out=$(nix eval --raw "$flake_ref#packages.$sys.kupo-musl"); then
echo "nix eval failed for $sys"
exit 1
fi
if [ -z "$out" ]; then
echo "nix eval returned an empty path for $sys"
exit 1
fi
echo "[$sys] resolved store path: $out"
echo "[$sys] waiting for $out in cache..."
while true; do
if nix path-info --refresh --store https://cache.iog.io "$out" >/dev/null 2>&1; then
echo "[$sys] found in cache"
break
fi
if [ "$SECONDS" -ge "$deadline" ]; then
echo "Timed out after 30 minutes waiting for Hydra cache ($sys path: $out)"
exit 1
fi
echo "[$sys] not in cache yet, waiting 30s..."
sleep 30
done
done

# Place each architecture's static binary at the path the Dockerfile's
# `COPY ./bin/kupo-${TARGETARCH}` expects (amd64 / arm64).
- name: Download kupo binaries from Hydra cache
run: |
flake_ref="git+https://github.com/$GITHUB_REPOSITORY?rev=$TARGET_SHA&submodules=1"
mkdir -p bin
for pair in "x86_64-linux:amd64" "aarch64-linux:arm64"; do
sys="${pair%%:*}"
arch="${pair##*:}"
nix build --builders "" --max-jobs 0 "$flake_ref#packages.$sys.kupo-musl" -o "result-$arch"
cp "result-$arch/bin/kupo" "bin/kupo-$arch"
done

- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Each architecture is built and pushed on its own as '<base>-amd64' /
# '<base>-arm64' (so the single-arch images are tagged from the start, never
# left as orphaned 'sha256:...' versions), then the multi-arch manifest lists
# are composed from those per-arch digests in a later step.
#
# kupo-base the canonical tag the per-arch builds attach '-<arch>' to
# kupo a space-separated list of every final multi-arch tag
- name: Compute image tags
id: tags
run: |
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/kupo"
# Replace any run of characters invalid in a Docker tag with a single
# '-' (deleting them could collapse distinct refs to the same tag),
# then trim leading/trailing separators.
version=$(printf '%s' "$GITHUB_REF_NAME" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^[-.]+//; s/[-.]+$//')
if ! [[ "$version" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]]; then
echo "::error::Cannot derive a valid image tag from git tag '$GITHUB_REF_NAME' (got '$version')."
exit 1
fi
{
echo "kupo-base=$image:$version"
echo "kupo=$image:$version $image:latest"
} >> "$GITHUB_OUTPUT"

# --- Per-architecture builds: each pushes a single '<base>-<arch>' tag. ---

- name: Build and push kupo (amd64)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: kupo
platforms: linux/amd64
provenance: false
push: true
tags: ${{ steps.tags.outputs.kupo-base }}-amd64

- name: Build and push kupo (arm64)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: kupo
platforms: linux/arm64
provenance: false
push: true
tags: ${{ steps.tags.outputs.kupo-base }}-arm64

# --- Compose multi-arch manifest lists from the per-arch images. ---
# Every final tag becomes a manifest list pointing at the two already-tagged
# '<base>-amd64' / '<base>-arm64' images by digest. No new single-arch
# manifests are created, so nothing is left untagged.
- name: Compose multi-arch manifests
run: |
set -euo pipefail
base='${{ steps.tags.outputs.kupo-base }}'
repo="${base%:*}"
amd64=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$base-amd64" | jq -er .digest)
arm64=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$base-arm64" | jq -er .digest)
for tag in ${{ steps.tags.outputs.kupo }}; do
docker buildx imagetools create -t "$tag" "$repo@$amd64" "$repo@$arm64"
done
Loading
Loading