Skip to content

security-audit

security-audit #8

name: security-audit
# Audits this repo against SECURITY.md. Reusable: runs nightly via the
# schedule trigger, on-demand via workflow_dispatch, and is called from
# release.yml as a precondition to publishing.
on:
schedule:
- cron: "21 4 * * *"
workflow_dispatch:
workflow_call:
permissions:
contents: read
actions: read
issues: write
id-token: write
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 20
# The AUDIT_PAT (read-only Administration + Secrets + Environments)
# lives in this environment, whose deployment-branch-policy admits
# only `main` and `v*` tags — refs that the §3 rulesets reserve to
# admins. A bot-pushed feature branch cannot reach this job at all
# (GitHub rejects the run before any step starts), so the PAT
# cannot be exfiltrated through a hand-authored workflow on a
# non-admin-gated ref.
environment:
name: security-audit
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 11.0.6
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Verify AUDIT_PAT is provisioned
env:
AUDIT_PAT: ${{ secrets.AUDIT_PAT }}
run: |
[ -n "$AUDIT_PAT" ] && exit 0
echo "FAIL" > audit-status.txt
echo "**FAIL** — \`AUDIT_PAT\` is not present in the \`security-audit\` environment. See [SECURITY.md > CI Validation Contract](https://github.com/$GITHUB_REPOSITORY/blob/main/SECURITY.md#ci-validation-contract) for provisioning." > audit-report.md
echo "::error::AUDIT_PAT secret is not set."
exit 1
- name: Audit against SECURITY.md
uses: anthropics/claude-code-action@4481e6d3c7bbb88db2a928ca3444c536f589c7c1 # v1
env:
# AUDIT_PAT is fine-grained, read-only Administration +
# Secrets + Environments. Required (the previous step
# refuses to proceed without it), so no fallback to
# github.token.
GH_TOKEN: ${{ secrets.AUDIT_PAT }}
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Without an explicit allowlist the action defaults to a
# restrictive set that excludes Bash and Write, so an
# auditing prompt that wants to run `gh api` and produce a
# report file racks up permission denials and exits without
# writing audit-status.txt.
claude_args: '--allowed-tools "Read,Write,Edit,Bash,Grep,Glob"'
prompt: |
Audit this repository against SECURITY.md. The `FAIL IF`
lines are concrete checks; the spec also says the list is
not exhaustive, so flag any other security hole you find.
For each `FAIL IF`, run the mechanical check (`gh api`,
grep, file presence, or a script) and record PASS or FAIL
with concrete evidence — file path and line number, API
response excerpt, or command output. Then do a qualitative
pass over `.github/workflows/`, `.config/tend.yaml`,
`.github/dependabot.yml`, `scripts/`, and any code that
touches secrets.
`$GH_TOKEN` is the `AUDIT_PAT` — a fine-grained, read-only
PAT covering Administration + Secrets + Environments,
guaranteed present by the previous step. If `gh api`
returns 403 on any check, record FAIL with the note "PAT
scope drifted from SECURITY.md"; reserve `UNVERIFIABLE`
only for genuinely indeterminable cases (e.g. a transient
network error) and explain why.
Write `audit-report.md` with three sections:
- `## FAIL IF results` — one line per check with PASS/FAIL
and evidence
- `## Qualitative findings` — severity BLOCKER / WARNING / INFO
- `## Summary` — overall PASS or FAIL with a one-paragraph
rationale
Write `PASS` or `FAIL` (no other text) to `audit-status.txt`.
Status is FAIL if any `FAIL IF` is violated or any
qualitative finding is BLOCKER. Do not call `exit` — the
workflow inspects the status file.
- name: Surface result, file or close issue
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eo pipefail
STATUS=$(tr -d '[:space:]' < audit-status.txt 2>/dev/null || echo "FAIL")
DATE=$(date -u +%Y-%m-%dT%H:%MZ)
RUN_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# Idempotent label creation; ignore "already exists" errors.
gh label create security-audit-failure \
--color B60205 --description "Security audit failure" 2>/dev/null || true
if [ "$STATUS" = "PASS" ]; then
# Auto-close any open audit-failure issues so the issue
# tracker reflects the live state.
for n in $(gh issue list --label security-audit-failure \
--state open --json number --jq '.[].number'); do
gh issue close "$n" --comment "Audit passed at $DATE. [Run]($RUN_URL)"
done
echo "Audit passed."
exit 0
fi
if [ ! -s audit-report.md ]; then
printf '%s\n' \
"Audit step produced no \`audit-report.md\`. See workflow run logs." \
> audit-report.md
fi
{
echo "Audit failed at $DATE. [Run]($RUN_URL)"
echo
cat audit-report.md
} > audit-comment.md
EXISTING=$(gh issue list --label security-audit-failure \
--state open --json number --jq '.[0].number' || true)
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body-file audit-comment.md
echo "Appended re-audit failure to issue #$EXISTING"
else
gh issue create \
--title "[security-audit] FAIL on $(date -u +%Y-%m-%d)" \
--label security-audit-failure \
--body-file audit-comment.md
fi
exit 1