Skip to content

Security Audit Label Guard #3704

Security Audit Label Guard

Security Audit Label Guard #3704

# Security-audit label guard — server-side companion to the harness
# sec-review audit gate.
#
# The clearance label `sec-review:approved` may only stand while the PR's
# security audit is green AND no guardrail-2 REQUEST-CHANGES verdict is
# outstanding. The harness hook withholds the label at APPLY time for commands
# run through a session; this catches the out-of-band case — the label applied
# from a terminal, an audit that goes RED after the label was added, or a
# REQUEST-CHANGES verdict posted after the label (the revealui#1910 miss) — by
# REVOKING a premature label and commenting why. Fires on label / review /
# comment / check_suite events so a fresh REQUEST-CHANGES un-sticks the label.
#
# CONSERVATIVE: revokes only on a definitive audit FAILURE (never pending /
# missing / cancelled), so it cannot false-revoke during the audit's in-flight
# window. Deterministic (check conclusions only, no model) — keeps ADR
# 2026-07-10-automated-security-review-eval condition 4 (additive-to-CI) intact.
#
# Overrides (ADR condition 9): repo variable SEC_AUDIT_GATE_DISABLED='true' is
# the kill switch; the per-PR label `sec-audit-override` exempts one PR.
name: Security Audit Label Guard
on:
# GAP-376: labeled path uses pull_request_target so the workflow definition
# is base-provenance (same class as the security review gate cutover).
# issue_comment / check_suite already run from the default branch (trusted).
# pull_request_review has no _target form — keep as-is (re-eval only).
pull_request_target:
branches: [test, main]
types: [labeled]
pull_request_review:
types: [submitted, edited, dismissed]
issue_comment:
types: [created, edited]
check_suite:
types: [completed]
concurrency:
group: sec-audit-label-guard-${{ github.event.pull_request.number || github.event.issue.number || github.event.check_suite.id }}
cancel-in-progress: false
permissions:
contents: read
checks: read
pull-requests: write
jobs:
guard:
name: Security audit label guard
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
# Evaluate with the BASE branch's copy of the decision scripts (same
# provenance rule as security-review-gate.yml). On issue_comment and
# check_suite events there is no pull_request payload, so this
# expression is empty and checkout falls back to the default-branch
# ref those events already run from.
ref: ${{ github.event.pull_request.base.sha }}
# GAP-408: guardrail2-verdict.cjs is a thin adapter over the
# @revealui/harnesses gates module. Builds it from THIS checkout's
# own source (packages/harnesses/ + packages/dev/ for the shared
# tsconfig, plus every package.json so pnpm can resolve the
# workspace filter) rather than installing a published npm artifact
# — see security-review-gate.yml's matching comment for why (a
# publish-pinned dependency would leave the gate fail-closed on an
# unbounded window until the next manual stable release). The
# resolver's npm-install fallback (REVEALUI_HARNESSES_DIR) stays in
# gates-resolver.cjs for the .jv side, unused here.
sparse-checkout: |
scripts/validate/sec-audit-label-decision.cjs
scripts/validate/guardrail2-verdict.cjs
scripts/validate/gates-resolver.cjs
package.json
pnpm-workspace.yaml
pnpm-lock.yaml
.npmrc
packages/*/package.json
apps/*/package.json
packages/harnesses/
packages/dev/
sparse-checkout-cone-mode: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: '10.28.2'
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: Build @revealui/harnesses gates (CJS for gates-resolver)
run: |
set -euo pipefail
# Same durable sparse contract as security-review-gate.yml:
# tsup.gates-cjs.ts → dist/gates/index.cjs (gates-resolver SSOT).
pnpm install --frozen-lockfile --filter @revealui/harnesses --filter @revealui/dev
pnpm --filter @revealui/harnesses exec tsup --config tsup.gates-cjs.ts
- name: Guard the clearance label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
KILL_SWITCH: ${{ vars.SEC_AUDIT_GATE_DISABLED }}
run: |
set -euo pipefail
KILL=false
if [ "${KILL_SWITCH:-}" = "true" ]; then KILL=true; fi
# PR numbers from whichever event fired, read from the event payload:
# a labeled/review event carries .pull_request; a check_suite carries
# .check_suite.pull_requests[]; an issue_comment carries .issue.number
# (only for PRs — guarded on .issue.pull_request). De-duplicated, blanks dropped.
prs=$(jq -r '(.pull_request.number // empty), (.check_suite.pull_requests[]?.number // empty), (if .issue.pull_request then .issue.number else empty end)' \
"$GITHUB_EVENT_PATH" | grep -E '^[0-9]+$' | sort -u || true)
if [ -z "$prs" ]; then
echo "No associated PR — nothing to guard."
exit 0
fi
for pr in $prs; do
echo "::group::PR #$pr"
state=$(gh pr view "$pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo "")
if [ "$state" != "OPEN" ]; then
echo "PR #$pr is not open ($state) — skip."; echo "::endgroup::"; continue
fi
sha=$(gh pr view "$pr" --repo "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
labels=$(gh pr view "$pr" --repo "$REPO" --json labels --jq '[.labels[].name]' 2>/dev/null || true)
if [ -z "$sha" ] || [ -z "$labels" ]; then
echo "::warning::PR #$pr — could not read PR metadata (transient API error?); skipping."
echo "::endgroup::"; continue
fi
# Guardrail-2 verdict inputs: PR author + review + comment bodies. The
# decision script parses the `<!-- guardrail2-verdict: ... -->` markers.
# Default to empty on a transient error — an empty set yields no hold,
# and the label-guard revoke is defense-in-depth behind the required
# security-review-gate status check, which independently holds the merge.
prAuthor=$(gh pr view "$pr" --repo "$REPO" --json author --jq '.author.login' 2>/dev/null || echo "")
reviews=$(gh pr view "$pr" --repo "$REPO" --json reviews --jq '.reviews' 2>/dev/null || echo "[]")
comments=$(gh pr view "$pr" --repo "$REPO" --json comments --jq '.comments' 2>/dev/null || echo "[]")
[ -n "$reviews" ] || reviews="[]"
[ -n "$comments" ] || comments="[]"
# Fetch check-runs resiliently. A transient gh-API 5xx can return an
# HTML body, which crashes jq ("invalid character '<'"). Fetch RAW
# (no gh --jq so gh's own jq pass can't crash), retry a few times,
# then SKIP — never error the workflow. Fail-safe: a skip performs
# no revoke, and the next check_suite event re-evaluates the PR.
checks=""
for attempt in 1 2 3; do
raw=$(gh api "repos/$REPO/commits/$sha/check-runs" --paginate 2>/dev/null || true)
if [ -n "$raw" ]; then
parsed=$(printf '%s' "$raw" \
| jq -s '[.[].check_runs[]? | {name: .name, conclusion: .conclusion, status: .status}]' 2>/dev/null || true)
if [ -n "$parsed" ]; then checks="$parsed"; break; fi
fi
sleep 2
done
if [ -z "$checks" ]; then
echo "::warning::PR #$pr — check-runs API unavailable or non-JSON after retries; skipping (fail-safe, no revoke). The next check_suite event re-evaluates."
echo "::endgroup::"; continue
fi
payload=$(jq -n \
--argjson checkRuns "$checks" \
--argjson labels "$labels" \
--argjson killSwitch "$KILL" \
--argjson reviews "$reviews" \
--argjson comments "$comments" \
--arg prAuthor "$prAuthor" \
'{checkRuns: $checkRuns, labels: $labels, killSwitch: $killSwitch, reviews: $reviews, comments: $comments, prAuthor: $prAuthor}')
decision=$(printf '%s' "$payload" | node scripts/validate/sec-audit-label-decision.cjs)
action=$(printf '%s' "$decision" | awk '{print $1}')
reason=$(printf '%s' "$decision" | cut -d' ' -f2-)
echo "decision: $action — $reason"
if [ "$action" = "revoke" ]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "sec-review:approved"
body=$(printf '%s\n' \
"🔒 **sec-review:approved was removed by the security-audit label guard.**" \
"" \
"Reason: $reason" \
"" \
"The clearance label may only stand while the security audit is green on the current head AND no guardrail-2 REQUEST-CHANGES verdict is outstanding. Resolve the reason above (a passing audit, or a later non-author guardrail-2 APPROVE marker) and re-apply the label. The guard is deterministic and additive — it never merges around a required check, only withholds a premature approval." \
"" \
"Overrides: apply the \`sec-audit-override\` label to exempt this PR, or set the repo variable \`SEC_AUDIT_GATE_DISABLED=true\` as a kill switch.")
gh pr comment "$pr" --repo "$REPO" --body "$body"
echo "Removed sec-review:approved from PR #$pr."
fi
echo "::endgroup::"
done