Repository navigation
Security Audit Label Guard #3704
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |