diff --git a/.github/workflows/pr-governance-tests.yml b/.github/workflows/pr-governance-tests.yml new file mode 100644 index 0000000..8d07c9c --- /dev/null +++ b/.github/workflows/pr-governance-tests.yml @@ -0,0 +1,34 @@ +name: Tests + +# The v1 branch is the trust anchor for every consumer repository, so this +# workflow is its required status check. Nothing reaches v1 without it. + +on: + pull_request: + push: + branches: [main, v1] + +permissions: + contents: read + +jobs: + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test the gate, labels, reviewer evidence, runner and review request + run: >- + node --test pr-governance/scripts/test-gate.cjs + pr-governance/scripts/test-labels.cjs pr-governance/scripts/test-codex.cjs + pr-governance/scripts/test-main.cjs pr-governance/scripts/test-runner.cjs + pr-governance/scripts/test-nudge.cjs + - name: Install the pinned scanner + run: bash pr-governance/scripts/install-gitleaks.sh + - name: Test the scanner + env: + GITLEAKS_BIN: ${{ runner.temp }}/gitleaks-bin/gitleaks + run: python3 pr-governance/scripts/test_secret_scan.py diff --git a/pr-governance/.gitleaks.toml b/pr-governance/.gitleaks.toml new file mode 100644 index 0000000..860d654 --- /dev/null +++ b/pr-governance/.gitleaks.toml @@ -0,0 +1,68 @@ +title = "GdeiAssistant credential detection" + +[extend] +useDefault = true + +[[rules]] +id = "mongodb-userinfo-password" +description = "MongoDB URI containing a userinfo password" +regex = '''(?i)mongodb(?:\+srv)?://[^\s:/@]+:([^\s/@]+)@''' +secretGroup = 1 + +[[rules.allowlists]] +description = "Synthetic values in established test fixture directories only" +condition = "AND" +regexTarget = "secret" +paths = ['''^tests/'''] +regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$'''] + +[[rules.allowlists]] +description = "Runtime-generated URI expression in the scanner regression source" +condition = "AND" +regexTarget = "secret" +paths = ['''(?:^|/)(?:\.github/)?scripts/test_secret_scan\.py$'''] +regexes = ['''^\{fake_value\(\)\}$'''] + +[[rules]] +id = "alibaba-access-key-secret" +description = "Alibaba Cloud AccessKey Secret assignment" +regex = '''(?i)access[_-]?key(?:[_-]?secret)?["']?\s*[:=]\s*["']?([a-z0-9]{30})(?:["'\s,;]|$)''' +secretGroup = 1 + +[[rules.allowlists]] +description = "Synthetic values in established test fixture directories only" +condition = "AND" +regexTarget = "secret" +paths = ['''^tests/'''] +regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$'''] + +[[rules]] +id = "configuration-secret-value" +description = "Non-placeholder credential in a configuration, log or JUnit XML assignment" +path = '''(?:\.(?:properties|ya?ml|env|conf|ini|log|txt|xml)$|(?:^|/)\.env(?:\.[^/]+)?$)''' +regex = '''(?im)(?:^||$)''' +secretGroup = 1 + +[[rules.allowlists]] +description = "Synthetic values in established test fixture directories only" +condition = "AND" +regexTarget = "secret" +paths = ['''^tests/'''] +regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$'''] + +[[allowlists]] +description = "Explicit placeholder values, never a blanket source-path exclusion" +regexTarget = "secret" +regexes = [ + '''(?i)^(?:REDACTED|(?:your[_.-])?(?:secret|token|password|appsecret|access[_.-]?key)(?:[_.-](?:here|example|placeholder))?|change[_.-]?me|replace[_.-]?me|<[^>]+>|\$\{[A-Za-z_][A-Za-z0-9_]*\}|x{8,}|0{16,})$''', +] + +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "Synthetic values in established test fixture directories only" +condition = "AND" +regexTarget = "secret" +paths = ['''^tests/'''] +regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$'''] diff --git a/pr-governance/README.md b/pr-governance/README.md new file mode 100644 index 0000000..b21cae3 --- /dev/null +++ b/pr-governance/README.md @@ -0,0 +1,125 @@ +# Organization PR governance + +One action and four caller workflows shared by every public repository in the +organization. The reviewer is Codex; the coding agent may be anything. No +workflow identifies or depends on the agent that wrote a pull request. + +## What is enforced + +A pull request may merge when all of these hold: + +- `pr-template` — the body fills the AI assistance, what changed and how + verified sections. +- `ai-review` — the newest Codex receipt names the current head, reports a + completed review, has no unresolved finding, and the head contains the + current base head. +- `risk-gate` — the resolved risk tier allows the path. Low risk passes on the + reviewer receipt. High risk also needs one clearing label that was recorded + on this exact head. +- `Gitleaks` — no credential in the commits the pull request adds. +- Repository-specific checks (`test`, `Documentation check`, `Repository + hygiene`, `Commit message convention`, …) stay in each repository. + +Branch protection, not this action, is the merge constraint. The action only +publishes evidence; it cannot merge, label, or write repository contents. + +## Risk tiers + +| Tier | Typical paths | Requirement | +| --- | --- | --- | +| R0 | prose, docs | template and reviewer receipt | +| R1 | ordinary source | template and reviewer receipt | +| R2 | auth, secrets, deploy, runtime, strategy, config, manifests | one clearing label bound to the head | + +The tiers come from the engine's path rules plus the repository's `r2_paths`. +`r0_paths` can only lower a path that has no sensitive token. + +Clearing labels: `owner-approved` (owner only) and `agent-approved` (any actor +with write permission). A label added by a coding agent is evidence that the +agent judged the change, not a substitute for the reviewer receipt. Only the +owner may add or remove `owner-approved`; agents must never touch it. + +## Files + +Everything below lives under `pr-governance/` so it can share a repository with +organization community-health files. + +| Path | Purpose | +| --- | --- | +| `action.yml` | `mode: gate`, `nudge` or `scan` | +| `scripts/gate.cjs` | classification, evidence and verdict | +| `scripts/runner.cjs` | collection, status publication, label recording | +| `scripts/main.cjs` | trusted policy loading and mode dispatch | +| `scripts/nudge.cjs` | one review request per head | +| `scripts/secret_scan.py` | range, history baseline and artifact scans | +| `scripts/install-gitleaks.sh` | checksum-verified Gitleaks 8.30.1 | +| `.gitleaks.toml` | organization rules and narrow synthetic allowlists | +| `policy.schema.json` | `pr-governance/v3` schema | +| `policy.default.json` | starting policy | +| `templates/` | caller workflows to copy into a repository | + +## Wiring a repository + +1. Copy `.github/governance/policy.json` from `policy.default.json` and set + `reviewer` and repository-specific `r2_paths`. Leave `owner` out: account + identities belong in protected configuration, so the action reads + `RISK_GATE_OWNER_ID` and `RISK_GATE_OWNER` from repository or organization + variables and replaces `owner` when they are set. +2. Copy `templates/risk-gate.yml`, `templates/codex-nudge.yml`, + `templates/gitleaks.yml` and `templates/weekly-secret-scan.yml` into + `.github/workflows/`, replacing `__GOVERNANCE_REPO__` with the + `owner/repository` of this governance repository and `__GOVERNANCE_SHA__` + with the full commit SHA at the head of `v1`. Copy + `templates/pr-review-signal.yml` unchanged as well: review events on fork + and Dependabot pull requests get a read-only token, so the gate is started + by that workflow's completion instead. +3. Add the required contexts to branch protection on the default branch: + `pr-template`, `ai-review`, `risk-gate`, `Gitleaks`, and the repository's + own checks. Keep `strict` (require branches to be up to date) and + `enforce_admins` on. +4. Add the `SECRET_SCAN_BASELINE_B64` secret before enabling the weekly scan. + The scan refuses to run without it. + +The owner identity comes from the `RISK_GATE_OWNER_ID` and `RISK_GATE_OWNER` +variables. A policy that still carries `owner` keeps working until the +variables are set, but no account identity is required in the file. + +## Trust model + +- Workflows never check out or execute pull request code. +- The action is pinned to a full commit SHA of this repository, not by the + repository under judgement. Update the pins after `v1` moves. +- The policy is read from the default branch head, so a pull request cannot + change the rules that judge it. +- Reviewer evidence is a marker comment written by the configured reviewer + identity. Screenshots, prose and labels are not evidence. +- A label only counts for the head the `labeled` event named. Revoking and + re-adding it does not replay an older record. +- Status writes stop before GitHub's 1000-per-context limit; the last slot is + reserved for a blocking state, so a head that runs out of slots stays blocked + instead of silently passing. + +## Modes + +`policy.json` `mode` controls the outcome: + +- `enforce` (default) — publish statuses and fail the run when any pull request + fails. +- `report-only` — publish advisory statuses, warn instead of failing. Use for + the first week of a migration. +- `local` — publish nothing. Used by the private-repository track, where branch + protection is unavailable. + +## Changing the shared action + +Open a pull request against `v1`. The `test` check must pass. Merging changes +the behavior of every consumer at its next run, so the digest of the policy is +part of every receipt: a policy change invalidates older receipts and asks for +a fresh review instead of silently reusing one. + +## Private repositories + +GitHub Free does not provide branch protection or secret scanning for private +repositories, and hosted Actions may be billing-blocked. Use the local track: +the same policy file, a pre-push hook and `scripts/local` commands recording +the same receipt and scan results locally. See `docs/private-repositories.md`. diff --git a/pr-governance/action.yml b/pr-governance/action.yml new file mode 100644 index 0000000..9a532c9 --- /dev/null +++ b/pr-governance/action.yml @@ -0,0 +1,92 @@ +name: PR governance +description: Evaluate pull request governance evidence, request a Codex review, or scan for secrets. +inputs: + mode: + description: "gate, nudge or scan" + required: true + full-history: + description: "Scan every fetched branch and its complete history" + required: false + default: "false" + artifact-name: + description: "Artifact name for the aggregate-only history summary" + required: false + default: "secret-scan-summary" +outputs: {} +runs: + using: composite + steps: + - name: Evaluate pull request governance + if: inputs.mode == 'gate' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GOVERNANCE_ACTION_PATH: ${{ github.action_path }} + with: + script: | + const path = require('node:path'); + const { runGate } = require(path.join(process.env.GOVERNANCE_ACTION_PATH, 'scripts', 'main.cjs')); + await runGate({ github, context, core }); + + - name: Request a Codex review when the current head has no receipt + if: inputs.mode == 'nudge' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GOVERNANCE_ACTION_PATH: ${{ github.action_path }} + with: + script: | + const path = require('node:path'); + const { run } = require(path.join(process.env.GOVERNANCE_ACTION_PATH, 'scripts', 'nudge.cjs')); + await run({ github, context, core }); + + - name: Install checksum-verified Gitleaks + if: inputs.mode == 'scan' + shell: bash + env: + GOVERNANCE_ACTION_PATH: ${{ github.action_path }} + run: bash "$GOVERNANCE_ACTION_PATH/scripts/install-gitleaks.sh" + + - name: Scan + if: inputs.mode == 'scan' + shell: bash + env: + GOVERNANCE_ACTION_PATH: ${{ github.action_path }} + FULL_HISTORY: ${{ inputs.full-history }} + BASELINE_B64: ${{ env.SECRET_SCAN_BASELINE_B64 }} + run: | + set -euo pipefail + binary="$RUNNER_TEMP/gitleaks-bin/gitleaks" + if [[ "$FULL_HISTORY" == "true" ]]; then + if [[ -z "${BASELINE_B64:-}" ]]; then + echo "SECRET_SCAN_BASELINE_B64 is not configured; refusing to scan without a baseline" >&2 + exit 1 + fi + printf '%s' "$BASELINE_B64" | base64 --decode > "$RUNNER_TEMP/secret-baseline.json" + python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" history \ + --root "$GITHUB_WORKSPACE" \ + --gitleaks "$binary" \ + --baseline "$RUNNER_TEMP/secret-baseline.json" \ + --summary "$RUNNER_TEMP/secret-summary.json" + ARTIFACT_PATHS="$RUNNER_TEMP/secret-summary.json" \ + python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" artifacts --gitleaks "$binary" + exit 0 + fi + base="$(jq -r '.pull_request.base.sha // .before // empty' "$GITHUB_EVENT_PATH")" + head="$(jq -r '.pull_request.head.sha // .after // .sha // empty' "$GITHUB_EVENT_PATH")" + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + echo "No base commit is available for this event; the pull request scan covers the branch" >&2 + exit 0 + fi + python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" range \ + --root "$GITHUB_WORKSPACE" \ + --base "$base" \ + --head "$head" \ + --gitleaks "$binary" + + - name: Upload aggregate-only history summary + if: inputs.mode == 'scan' && inputs.full-history == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/secret-summary.json + if-no-files-found: error + retention-days: 3 diff --git a/pr-governance/policy.default.json b/pr-governance/policy.default.json new file mode 100644 index 0000000..abb4451 --- /dev/null +++ b/pr-governance/policy.default.json @@ -0,0 +1,24 @@ +{ + "version": "pr-governance/v3", + "mode": "enforce", + "reviewer": { + "provider": "codex", + "app_id": 1144995, + "user_id": 199175422, + "login": "chatgpt-codex-connector[bot]" + }, + "r0_paths": [ + "README.md", + "CHANGELOG.md", + "docs/prose/" + ], + "r2_paths": [ + "scripts/", + ".github/governance/", + ".github/scripts/", + "policy.schema.json", + "policy.default.json" + ], + "dependabot_template_exemption": true, + "max_evidence_age_hours": 168 +} diff --git a/pr-governance/policy.schema.json b/pr-governance/policy.schema.json new file mode 100644 index 0000000..b9a80b3 --- /dev/null +++ b/pr-governance/policy.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "pr-governance/v3", + "title": "PR governance policy", + "description": "Trusted per-repository policy read from the default branch head. Never read from the pull request head.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "mode", + "reviewer", + "r0_paths", + "r2_paths", + "dependabot_template_exemption", + "max_evidence_age_hours" + ], + "properties": { + "version": { + "const": "pr-governance/v3" + }, + "mode": { + "description": "enforce publishes and fails; report-only publishes advisory statuses and never fails the run; local publishes nothing.", + "enum": [ + "enforce", + "local", + "report-only" + ] + }, + "reviewer": { + "description": "The only fixed identity in the gate. Reviewer-authored evidence is the sole review source.", + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "app_id", + "user_id", + "login" + ], + "properties": { + "provider": { + "enum": [ + "codex" + ] + }, + "app_id": { + "type": "integer", + "minimum": 1 + }, + "user_id": { + "type": "integer", + "minimum": 1 + }, + "login": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*\\[bot\\]$" + } + } + }, + "owner": { + "description": "Optional. The only actor whose label event can claim owner-approved. Prefer setting RISK_GATE_OWNER_ID and RISK_GATE_OWNER as repository or organization variables; the action then replaces this object and the policy carries no account identity.", + "type": "object", + "additionalProperties": false, + "required": [ + "user_id", + "login" + ], + "properties": { + "user_id": { + "type": "integer", + "minimum": 1 + }, + "login": { + "type": "string", + "minLength": 1 + } + } + }, + "r0_paths": { + "description": "Prose paths that may be classified R0 when the change has no sensitive keyword. Trailing slash matches a prefix.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[^/\\\\][^\\\\]*$", + "minLength": 1 + } + }, + "r2_paths": { + "description": "Repository-specific high-risk paths; added to the canonical heuristics.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[^/\\\\][^\\\\]*$", + "minLength": 1 + } + }, + "dependabot_template_exemption": { + "description": "Skip the pull request template check for verified Dependabot heads.", + "type": "boolean" + }, + "max_evidence_age_hours": { + "description": "Maximum age of reviewer evidence, in hours.", + "type": "number", + "minimum": 1, + "maximum": 720 + } + } +} diff --git a/pr-governance/scripts/fixtures.cjs b/pr-governance/scripts/fixtures.cjs new file mode 100644 index 0000000..4401f57 --- /dev/null +++ b/pr-governance/scripts/fixtures.cjs @@ -0,0 +1,157 @@ +'use strict'; + +// Synthetic fixtures shared by the test suites. Every identifier is either a +// documented public constant from policy.default.json or an obviously fake one; +// no real credential, comment body or personal data appears here. + +const policy = require('../policy.default.json'); + +const HEAD = 'a'.repeat(40); +const BASE = 'b'.repeat(40); +const POLICY_SHA = 'c'.repeat(64); +const NOW = 1_800_000_000; +const COMPLETE_TEMPLATE = '## AI assistance\nCodex was used for the gate.\n' + + '## What changed\nBind reviewer evidence to the head.\n' + + '## How verified\nOffline tests passed.'; + +const at = (offsetSeconds) => new Date((NOW + offsetSeconds) * 1000).toISOString(); + +function fixture(options = {}) { + const clone = structuredClone(policy); + // The shipped policy leaves the owner to protected variables, so fixtures + // inject a synthetic one for the label and digest assertions. + clone.owner ??= { user_id: 101, login: 'synthetic-owner' }; + if (options.policy) options.policy(clone); + const files = options.files || [{ + filename: options.path || 'src/view.js', + status: 'modified', + sha: 'd'.repeat(40), + additions: 1, + deletions: 1, + patch: '@@ -1 +1 @@\n-before\n+after', + }]; + const commits = options.commits || [{ + author: { id: 7, login: 'author' }, + committer: { id: 7, login: 'author' }, + commit: { verification: { verified: false }, committer: { email: 'author@example.invalid' } }, + }]; + return { + snapshot: { + repository: 'example-org/example-repo', + policyCommit: BASE, + complete: true, + pr: { + state: 'open', + number: options.number || 42, + user: { id: 7, login: 'author', type: 'User' }, + body: options.body ?? COMPLETE_TEMPLATE, + labels: [], + head: { sha: HEAD }, + base: { sha: BASE, ref: 'main', repo: { id: 100 } }, + changed_files: files.length, + commits: commits.length, + }, + files, + commits, + comments: [], + threads: [], + timeline: [], + statuses: [], + dependabotPolicy: options.dependabotPolicy ?? null, + }, + policy: clone, + }; +} + +const reviewerUser = clone => ({ id: clone.policy.reviewer.user_id, login: clone.policy.reviewer.login, type: 'Bot' }); + +// `codex-review-state:v1` — the canonical inline format. +function stateMarker(clone, review = {}, options = {}) { + const body = { + code_review: { + commit_sha: HEAD, + has_any_findings: false, + has_unresolved_findings: false, + status: 'completed', + ...review, + }, + security_review: {}, + }; + clone.snapshot.comments.push({ + id: options.id ?? 900 + clone.snapshot.comments.length, + user: reviewerUser(clone), + created_at: at(options.updated ?? -60), + updated_at: at(options.updated ?? -60), + body: `\n\n## Review summary\n`, + }); + return clone; +} + +// `codex-review-receipt:v1` — the module format. +function receiptMarker(clone, review = {}, options = {}) { + const body = { + review: { commit_sha: HEAD, coverage: 'complete', unresolved_findings: 0, ...review }, + }; + clone.snapshot.comments.push({ + id: options.id ?? 900 + clone.snapshot.comments.length, + user: reviewerUser(clone), + created_at: at(options.updated ?? -60), + updated_at: at(options.updated ?? -60), + body: `\n\nReview finished.\n`, + }); + return clone; +} + +function rawComment(clone, body, options = {}) { + clone.snapshot.comments.push({ + id: options.id ?? 900 + clone.snapshot.comments.length, + user: options.user || reviewerUser(clone), + created_at: at(options.updated ?? -60), + updated_at: at(options.updated ?? -60), + body, + }); + return clone; +} + +function thread(clone, options = {}) { + clone.snapshot.threads.push({ + id: options.id || 'PRRT_thread_1', + isResolved: options.resolved === true, + isOutdated: options.outdated === true, + author: { login: options.author ?? clone.policy.reviewer.login.replace(/\[bot\]$/, '') }, + }); + return clone; +} + +// A timeline label event plus the label it leaves on the pull request. +function labelEvent(clone, options = {}) { + const id = options.id ?? 5000 + clone.snapshot.timeline.length; + const name = options.name || 'owner-approved'; + const event = options.event || 'labeled'; + const actor = options.actor || { id: clone.policy.owner.user_id, login: clone.policy.owner.login }; + clone.snapshot.timeline.push({ id, event, label: { name }, actor, created_at: at(options.at ?? -300) }); + const present = clone.snapshot.pr.labels.filter(item => item.name !== name); + clone.snapshot.pr.labels = event === 'labeled' ? [...present, { name }] : present; + return { id, name, actor, event }; +} + +// The immutable `clearing-label` record written by the record_label step. +function clearRecord(clone, record, options = {}) { + clone.snapshot.statuses.push({ + context: 'clearing-label', + state: 'success', + description: options.description + ?? `${record.name} for PR ${clone.snapshot.pr.number} at event ${record.id} on this head`, + creator: { id: 41898282, login: 'github-actions[bot]' }, + }); + return clone; +} + +function approve(clone) { + return receiptMarker(clone); +} + +module.exports = { + policy, HEAD, BASE, POLICY_SHA, NOW, COMPLETE_TEMPLATE, at, + fixture, stateMarker, receiptMarker, rawComment, thread, labelEvent, clearRecord, approve, +}; diff --git a/pr-governance/scripts/gate.cjs b/pr-governance/scripts/gate.cjs new file mode 100644 index 0000000..3468f63 --- /dev/null +++ b/pr-governance/scripts/gate.cjs @@ -0,0 +1,563 @@ +'use strict'; + +// pr-governance/v3 evaluator. +// +// Pure functions only: no I/O, no timers and no Node built-ins. This file is +// embedded verbatim into the generated reusable workflow, so it has to run in +// any JavaScript host with nothing but the language itself. + +const SCHEMA = 'pr-governance/v3'; +const MODES = ['enforce', 'local', 'report-only']; +const POLICY_FIELDS = ['version', 'mode', 'reviewer', 'r0_paths', 'r2_paths', + 'dependabot_template_exemption', 'max_evidence_age_hours']; +const POLICY_OPTIONAL_FIELDS = ['owner']; +const REVIEWER_FIELDS = ['provider', 'app_id', 'user_id', 'login']; +const OWNER_FIELDS = ['user_id', 'login']; +const PROVIDERS = ['codex']; +const MARKER_NAMES = ['codex-review-receipt:v1', 'codex-review-state:v1']; +const COMPLETE_COVERAGE = ['complete', 'completed', 'full', 'reported_complete', 'all']; +const DEPENDABOT = 'dependabot[bot]'; +const CLEARING_LABELS = ['owner-approved', 'agent-approved']; + +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +// ---------------------------------------------------------------- digests --- + +function utf8Bytes(text) { + const bytes = []; + for (let index = 0; index < text.length; index++) { + const point = text.codePointAt(index); + if (point > 0xffff) index++; + if (point < 0x80) bytes.push(point); + else if (point < 0x800) bytes.push(0xc0 | (point >> 6), 0x80 | (point & 63)); + else if (point < 0x10000) bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 63), 0x80 | (point & 63)); + else bytes.push(0xf0 | (point >> 18), 0x80 | ((point >> 12) & 63), 0x80 | ((point >> 6) & 63), 0x80 | (point & 63)); + } + return Uint8Array.from(bytes); +} + +const ROUND = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +const rotate = (value, bits) => (value >>> bits) | (value << (32 - bits)); + +function sha256Hex(text) { + const bytes = utf8Bytes(String(text)); + const total = Math.ceil((bytes.length + 9) / 64) * 64; + const block = new Uint8Array(total); + block.set(bytes); + block[bytes.length] = 0x80; + const view = new DataView(block.buffer); + view.setUint32(total - 8, Math.floor((bytes.length * 8) / 0x100000000)); + view.setUint32(total - 4, (bytes.length * 8) >>> 0); + const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + const schedule = new Uint32Array(64); + for (let offset = 0; offset < total; offset += 64) { + for (let index = 0; index < 16; index++) schedule[index] = view.getUint32(offset + index * 4); + for (let index = 16; index < 64; index++) { + const a = schedule[index - 15]; + const b = schedule[index - 2]; + const s0 = rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3); + const s1 = rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10); + schedule[index] = (schedule[index - 16] + s0 + schedule[index - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = state; + for (let index = 0; index < 64; index++) { + const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25); + const choice = (e & f) ^ (~e & g); + const t1 = (h + s1 + choice + ROUND[index] + schedule[index]) >>> 0; + const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + majority) >>> 0; + h = g; g = f; f = e; e = (d + t1) >>> 0; + d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0; + state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0; + state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0; + } + return state.map(value => value.toString(16).padStart(8, '0')).join(''); +} + +// Sorted keys and no undefined leaves: the same evidence always hashes the same. +function canonical(value) { + if (value === undefined) return 'null'; + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +const digest = value => sha256Hex(canonical(value)); +const isSha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); +const matchesPath = (path, rule) => rule.endsWith('/') ? path.startsWith(rule) : path === rule; +const normalizeLogin = login => String(login || '').replace(/\[bot\]$/i, '').toLowerCase(); + +// ----------------------------------------------------------------- policy --- + +function sameKeys(object, expected, label, optional = []) { + assert(object && typeof object === 'object' && !Array.isArray(object), `Invalid ${label}`); + const allowed = new Set([...expected, ...optional]); + const actual = Object.keys(object); + assert(actual.every(key => allowed.has(key)) + && expected.every(key => Object.prototype.hasOwnProperty.call(object, key)), + `Invalid ${label} fields`); +} + +const isPositiveId = value => Number.isSafeInteger(value) && value > 0; + +function validatePolicy(policy) { + sameKeys(policy, POLICY_FIELDS, 'policy', POLICY_OPTIONAL_FIELDS); + assert(policy.version === SCHEMA, `Unsupported policy version: ${String(policy.version)}`); + assert(MODES.includes(policy.mode), `Unsupported policy mode: ${String(policy.mode)}`); + sameKeys(policy.reviewer, REVIEWER_FIELDS, 'reviewer'); + assert(PROVIDERS.includes(policy.reviewer.provider), `Unsupported reviewer provider: ${String(policy.reviewer.provider)}`); + assert(isPositiveId(policy.reviewer.app_id), 'Invalid reviewer app id'); + assert(isPositiveId(policy.reviewer.user_id), 'Invalid reviewer user id'); + assert(typeof policy.reviewer.login === 'string' && /^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$/.test(policy.reviewer.login), + 'Invalid reviewer login'); + // The owner comes from protected repository variables, so the committed + // policy may leave it out. When it is present, it must be well formed. + if (policy.owner !== undefined) { + sameKeys(policy.owner, OWNER_FIELDS, 'owner'); + assert(isPositiveId(policy.owner.user_id), 'Invalid owner user id'); + assert(typeof policy.owner.login === 'string' && policy.owner.login.length > 0, 'Invalid owner login'); + } + for (const field of ['r0_paths', 'r2_paths']) { + const rules = policy[field]; + assert(Array.isArray(rules), `Invalid ${field}`); + assert(new Set(rules).size === rules.length, `Duplicate rule in ${field}`); + for (const rule of rules) { + assert(typeof rule === 'string' && rule.length > 0 && !rule.startsWith('/') && !rule.includes('\\') + && !rule.replace(/\/$/, '').split('/').some(part => part === '..' || part === '.' || part === ''), + `Invalid ${field} rule: ${String(rule)}`); + } + } + assert(typeof policy.dependabot_template_exemption === 'boolean', 'Invalid dependabot_template_exemption'); + assert(typeof policy.max_evidence_age_hours === 'number' && policy.max_evidence_age_hours >= 1 + && policy.max_evidence_age_hours <= 720, 'Invalid max_evidence_age_hours'); + return policy; +} + +// --------------------------------------------------------- path classifyng --- + +const PROTECTED_TOKEN = new RegExp('(^|[/_.-])(auth(?:entication|orization)?|oauth|login|session|tokens?' + + '|credentials?|secrets?|iam|permissions?|privacy|storage|trad(?:e|es|ing)|execut(?:ion|or)s?|orders?' + + '|brokers?|risk|deploy(?:ment)?|release|runtime|strateg(?:y|ies)|governance|settings?|config(?:uration)?)' + + '([/_.-]|$)'); +const MANIFEST = new RegExp('(^|/)(dockerfile(?:\\..*)?|docker-compose[^/]*|compose\\.ya?ml|vercel\\.json' + + '|wrangler\\.(?:toml|jsonc?)|firebase\\.json|package(?:-lock)?\\.json|requirements[^/]*|pyproject\\.toml' + + '|setup\\.(?:py|cfg)|uv\\.lock|poetry\\.lock|yarn\\.lock|pnpm-lock\\.yaml|makefile|.*\\.config\\.[^/]+)$'); +const GOVERNANCE_SCRIPT = /^scripts\/[^/]*(?:check|verify|validate|hygiene|lint)[^/]*\.(?:py|sh|js|cjs|mjs|ts)$/; +const SECRET_MATERIAL = /\.(?:tf|tfvars|pem|key|p12|pfx)$/; + +function pathTier(path, policy) { + assert(typeof path === 'string' && path.length > 0 && !path.startsWith('/') && !path.includes('\\') + && !path.split('/').some(part => part === '..' || part === '.' || part === ''), 'Unknown path'); + const lowered = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + // Protected paths outrank prose, examples and fixtures: a document that names + // a credential is not low risk. + if (lowered.startsWith('.github/') + || /(^|\/)(agents|claude)\.md$/.test(lowered) + || /(^|\/)(codeowners|\.coderabbit\.ya?ml|\.env(?:[._-].*)?)$/.test(lowered) + || PROTECTED_TOKEN.test(lowered) + || MANIFEST.test(lowered) + || GOVERNANCE_SCRIPT.test(lowered) + || SECRET_MATERIAL.test(lowered) + || policy.r2_paths.some(rule => matchesPath(path, rule))) return 'R2'; + return policy.r0_paths.some(rule => matchesPath(path, rule)) && /\.(?:md|txt)$/.test(lowered) ? 'R0' : 'R1'; +} + +const isWorkflowPath = path => path.startsWith('.github/workflows/'); + +// A Dependabot repin keeps the action and changes only its ref. Swapping in a +// different action is a different change and takes the normal tier. +function pureActionRepin(file) { + if (typeof file.patch !== 'string' || !file.patch) return false; + const changed = file.patch.split('\n') + .filter(line => /^[+-]/.test(line) && !/^(?:\+\+\+|---)/.test(line)) + .map(line => ({ sign: line[0], text: line.slice(1) })); + const pinned = changed.filter(entry => /^\s*-?\s*uses\s*:/.test(entry.text)); + if (!pinned.length || pinned.length !== changed.length) return false; + const targets = sign => pinned.filter(entry => entry.sign === sign) + .map(entry => /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/.exec(entry.text)?.[1] ?? null); + const removed = targets('-'); + const added = targets('+'); + return removed.length > 0 && removed.length === added.length + && removed.every((target, index) => target !== null && target === added[index]); +} + +function classify(files, expected, policy, options = {}) { + assert(Array.isArray(files) && files.length > 0 && files.length === expected && files.length < 3000, + 'Incomplete file list'); + assert(new Set(files.map(file => file.filename)).size === files.length, 'Duplicate file'); + const dependabot = options.dependabot || {}; + const allowlisted = dependabot.detected && Array.isArray(dependabot.action_files) ? dependabot.action_files : []; + let tier = 'R0'; + const rules = []; + for (const file of files) { + assert(['added', 'modified', 'removed', 'renamed', 'copied', 'changed'].includes(file.status), 'Unknown file status'); + assert(isSha(file.sha), 'Missing file object SHA'); + assert(Number.isSafeInteger(file.additions) && file.additions >= 0 + && Number.isSafeInteger(file.deletions) && file.deletions >= 0, 'Unknown diff size'); + if (file.status === 'renamed') assert(file.previous_filename, 'Missing previous path'); + const paths = [file.filename, file.previous_filename].filter(Boolean); + let fileTier = paths.map(path => pathTier(path, policy)).sort().at(-1); + // Count actual hunk lines: missing or truncated patches cannot attest coverage. + const patch = file.patch || ''; + const lines = patch.split('\n'); + assert(lines.filter(line => line.startsWith('+')).length === file.additions + && lines.filter(line => line.startsWith('-')).length === file.deletions, 'Incomplete patch'); + assert(patch || (file.additions === 0 && file.deletions === 0 && file.status === 'renamed'), 'Missing patch'); + const changed = lines.filter(line => /^[+-]/.test(line)).map(line => line.slice(1)); + // R0 is deliberately narrow: even inline code, links and HTML need a reviewer. + if (fileTier === 'R0' && (file.status !== 'modified' + || changed.some(line => /[`~<>\[\]{}\\$]|^\s{4}|(?:https?:|javascript:|\b(?:curl|wget|npm|python|sudo|bash)\b)/i.test(line)))) fileTier = 'R1'; + if (paths.some(path => /\.(?:md|txt)$/.test(path)) + && changed.some(line => /\b(?:auth|iam|secret|credential|permission|trading|orders?|strategy|deploy|workflow|owner-approved)\b|认证|授权|凭据|交易|下单|部署/i.test(line))) fileTier = 'R2'; + // A verified Dependabot update inside an allowlisted workflow is nothing but + // a repin, so it may merge without a clearing label. Everything else in a + // workflow definition still needs one. + if (fileTier === 'R2' && dependabot.detected && allowlisted.includes(file.filename) + && isWorkflowPath(file.filename) && !file.previous_filename && pureActionRepin(file)) fileTier = 'R1'; + if (fileTier > tier) tier = fileTier; + rules.push({ paths, tier: fileTier }); + } + return { tier, rules }; +} + +// --------------------------------------------------------------- evidence --- + +function binding(snapshot, policySha) { + const pr = snapshot.pr; + assert(isSha(pr.head.sha) && isSha(pr.base.sha), 'Unknown commit'); + assert(isSha(snapshot.policyCommit), 'Unknown policy commit'); + return { + repository: snapshot.repository, + repository_id: pr.base?.repo?.id ?? null, + pr_number: pr.number, + head_sha: pr.head.sha, + base_sha: pr.base.sha, + policy_commit: snapshot.policyCommit, + policy_sha256: policySha, + diff_sha256: digest(snapshot.files.map(file => ({ + filename: file.filename, + previous_filename: file.previous_filename || null, + status: file.status, + sha: file.sha, + additions: file.additions, + deletions: file.deletions, + patch: file.patch || '', + })).sort((left, right) => (left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0))), + }; +} + +// Bodies never enter a digest or a log line: only identity and timing do. +const stableComment = comment => ({ + id: comment.id, + user: { id: comment.user?.id ?? null, login: comment.user?.login ?? null, type: comment.user?.type ?? null }, + created_at: comment.created_at ?? null, + updated_at: comment.updated_at ?? null, +}); + +const stableThread = thread => ({ + id: thread.id, + isResolved: thread.isResolved === true, + isOutdated: thread.isOutdated === true, + author: thread.author?.login ?? null, +}); + +const sameLogin = (user, reviewer) => Boolean(user) && normalizeLogin(user.login) === normalizeLogin(reviewer.login); + +function matchesReviewer(user, reviewer) { + return sameLogin(user, reviewer) && user.id === reviewer.user_id; +} + +// GraphQL returns the thread author login for every thread but the numeric id +// only when the viewer may read it, so a present id has to match and an absent +// one falls back to the normalised login. +function matchesThreadAuthor(author, reviewer) { + if (!sameLogin(author, reviewer)) return false; + if (author.id === undefined || author.id === null) return true; + return author.id === reviewer.user_id; +} + +// Collects every marker occurrence, terminated by `-->`. A marker whose body +// contains the terminator therefore fails to parse instead of being accepted. +function markerPayloads(body, name) { + const needle = new RegExp(`', start); + payloads.push(end < 0 ? null : body.slice(start, end).trim()); + } + return payloads; +} + +const carriesMarker = body => MARKER_NAMES.some(name => markerPayloads(body, name).length > 0); + +const REVIEW_TEXT = { + review_not_required: 'Low-risk prose: reviewer evidence not required', + review_complete: 'Reviewer evidence covers this head', + review_pending: 'Waiting for reviewer evidence on this head', + review_running: 'Reviewer review is still running on this head', + review_malformed: 'Newest reviewer marker is malformed or ambiguous', + review_stale: 'Reviewer evidence covers another commit', + review_findings: 'Reviewer reported unresolved findings', + review_findings_unknown: 'Reviewer findings state is not reported', + review_partial: 'Reviewer coverage is incomplete', + review_expired: 'Reviewer evidence exceeds the policy age limit', + review_threads: 'Unresolved reviewer threads remain on this head', +}; + +function normalizeMarker(format, payload) { + const review = format === 'codex-review-receipt:v1' ? payload?.review : payload?.code_review; + if (!review || typeof review !== 'object' || Array.isArray(review)) return null; + if (typeof review.commit_sha !== 'string') return null; + if (format === 'codex-review-receipt:v1') { + const unresolved = review.unresolved_findings; + let flagged = null; + if (typeof unresolved === 'boolean') flagged = unresolved; + else if (Number.isSafeInteger(unresolved) && unresolved >= 0) flagged = unresolved > 0; + const coverage = typeof review.coverage === 'string' ? review.coverage.trim().toLowerCase() : ''; + return { commit_sha: review.commit_sha, status: 'completed', unresolved: flagged, + coverage: COMPLETE_COVERAGE.includes(coverage) ? 'complete' : 'partial', coverage_raw: coverage || null }; + } + const unresolved = review.has_unresolved_findings; + return { + commit_sha: review.commit_sha, + status: typeof review.status === 'string' ? review.status : '', + unresolved: typeof unresolved === 'boolean' ? unresolved : null, + coverage: 'complete', + coverage_raw: null, + }; +} + +function reviewerEvidence(snapshot, policy, now) { + const reviewer = policy.reviewer; + const empty = { provider: reviewer.provider, state: 'pending', reason: 'review_pending', comment_id: null, + format: null, commit_sha: null, coverage: null, unresolved: null }; + const candidates = (snapshot.comments || []) + .filter(comment => matchesReviewer(comment.user, reviewer) && typeof comment.body === 'string' && carriesMarker(comment.body)) + .map(comment => { + assert(Number.isSafeInteger(comment.id) && comment.id > 0, 'Unknown comment metadata'); + return { comment, stamp: Date.parse(comment.updated_at) }; + }) + .sort((left, right) => (right.stamp || 0) - (left.stamp || 0) || right.comment.id - left.comment.id); + if (!candidates.length) return empty; + // The newest marker-bearing comment wins before its contents are judged: a + // newer malformed or blocking marker must not fall back to an older clean one. + const { comment, stamp } = candidates[0]; + const bound = { ...empty, comment_id: comment.id }; + const found = []; + for (const name of MARKER_NAMES) { + for (const payload of markerPayloads(comment.body, name)) found.push({ name, payload }); + } + if (found.length !== 1) return { ...bound, state: 'failure', reason: 'review_malformed' }; + let parsed; + try { + parsed = JSON.parse(found[0].payload); + } catch { + return { ...bound, state: 'failure', reason: 'review_malformed' }; + } + const evidence = normalizeMarker(found[0].name, parsed); + if (!evidence) return { ...bound, state: 'failure', reason: 'review_malformed' }; + Object.assign(bound, { format: found[0].name, commit_sha: evidence.commit_sha, + coverage: evidence.coverage, unresolved: evidence.unresolved }); + if (evidence.status !== 'completed') return { ...bound, state: 'pending', reason: 'review_running' }; + if (evidence.commit_sha !== snapshot.pr.head.sha) return { ...bound, state: 'failure', reason: 'review_stale' }; + if (evidence.unresolved === true) return { ...bound, state: 'failure', reason: 'review_findings' }; + if (evidence.unresolved === null) return { ...bound, state: 'failure', reason: 'review_findings_unknown' }; + if (evidence.coverage !== 'complete') return { ...bound, state: 'failure', reason: 'review_partial' }; + const limit = policy.max_evidence_age_hours * 3600; + if (!Number.isFinite(stamp)) return { ...bound, state: 'failure', reason: 'review_malformed' }; + if (now - Math.floor(stamp / 1000) > limit) return { ...bound, state: 'failure', reason: 'review_expired' }; + return { ...bound, state: 'success', reason: 'review_complete' }; +} + +function unresolvedReviewerThreads(snapshot, policy) { + return (snapshot.threads || []) + .filter(thread => thread.isResolved !== true && matchesThreadAuthor(thread.author, policy.reviewer)) + .map(thread => thread.id) + .sort(); +} + +function dependabotEvidence(snapshot, policy) { + const result = { detected: false, action_files: [], template_exempt: true === policy.dependabot_template_exemption }; + if (snapshot.pr.user?.login !== DEPENDABOT) return result; + const commits = snapshot.commits || []; + // The commits API truncates at 250 entries; an incomplete history cannot prove + // who wrote every commit in the head. + if (!commits.length || commits.length >= 250) return { ...result, template_exempt: false }; + const verified = commits.every(commit => commit.author?.login === DEPENDABOT + && commit.committer?.login === 'web-flow' + && commit.commit?.verification?.verified === true); + if (!verified) return { ...result, template_exempt: false }; + const policyFile = snapshot.dependabotPolicy; + const actionFiles = Array.isArray(policyFile?.action_files) + ? policyFile.action_files.filter(path => typeof path === 'string').sort() + : []; + return { detected: true, action_files: actionFiles, template_exempt: result.template_exempt }; +} + +function labelEvidence(snapshot, policy) { + const events = (snapshot.timeline || []).filter(event => event.event === 'labeled' || event.event === 'unlabeled'); + const present = new Set((snapshot.pr.labels || []).map(label => label.name)); + const records = {}; + for (const name of CLEARING_LABELS) { + const latest = events.filter(event => event.label?.name === name) + .sort((left, right) => right.id - left.id)[0]; + if (!latest || latest.event !== 'labeled') continue; + if (!present.has(name)) continue; + // The owner label claims a human confirmation; only the configured owner id + // can carry that claim. Any actor may claim agent-approved, and the actor is + // recorded in the digest because the label API already requires write access. + if (name === 'owner-approved' && latest.actor?.id !== policy.owner?.user_id) continue; + if (!Number.isSafeInteger(latest.id) || latest.id < 1 || !Number.isSafeInteger(latest.actor?.id)) continue; + const expected = `${name} for PR ${snapshot.pr.number} at event ${latest.id} on this head`; + const bound = (snapshot.statuses || []).some(status => status.context === 'clearing-label' + && status.state === 'success' && status.description === expected); + if (!bound) continue; + records[name] = { event_id: latest.id, actor_id: latest.actor.id, actor_login: latest.actor.login ?? null }; + } + // A human confirmation outranks an agent's when both are bound to this head. + const applied = records['owner-approved'] ? 'owner' : records['agent-approved'] ? 'agent' : null; + return { applied, records, present: [...present].filter(name => CLEARING_LABELS.includes(name)).sort() }; +} + +function templateVerdict(snapshot, policy, dependabot) { + if (dependabot?.detected && policy.dependabot_template_exemption === true) { + return { ok: true, exempt: true, state: 'success', description: 'Automated dependency update: template not required' }; + } + const ok = completeTemplate(snapshot.pr.body); + return { ok, exempt: false, state: ok ? 'success' : 'failure', + description: ok ? 'PR template sections complete' : 'Fill in AI, changes and verification sections' }; +} + +const GATE_TEXT = { + clearing_label_required: 'needs owner-approved or agent-approved', + pr_template_incomplete: 'PR template incomplete', + ai_review_pending: 'waiting for reviewer', +}; + +function evaluate(snapshot, policy, policySha, now = Math.floor(Date.now() / 1000)) { + validatePolicy(policy); + assert(typeof policySha === 'string' && /^[a-f0-9]{64}$/.test(policySha), 'Unknown policy digest'); + assert(snapshot && snapshot.complete === true, 'Incomplete snapshot'); + const pr = snapshot.pr; + assert(pr && pr.state === 'open', 'Only open pull requests are evaluated'); + assert(Number.isSafeInteger(pr.number) && pr.number > 0, 'Unknown pull request number'); + assert(Array.isArray(snapshot.commits) && snapshot.commits.length === pr.commits + && snapshot.commits.length > 0 && snapshot.commits.length < 250, 'Incomplete commit list'); + for (const field of ['comments', 'threads', 'timeline', 'statuses']) { + assert(Array.isArray(snapshot[field]), `Incomplete ${field}`); + } + const dependabot = dependabotEvidence(snapshot, policy); + const risk = classify(snapshot.files, pr.changed_files, policy, { dependabot }); + const expected = binding(snapshot, policySha); + const template = templateVerdict(snapshot, policy, dependabot); + const labels = labelEvidence(snapshot, policy); + const threads = unresolvedReviewerThreads(snapshot, policy); + let review = risk.tier === 'R0' + ? { provider: policy.reviewer.provider, state: 'success', reason: 'review_not_required', comment_id: null, + format: null, commit_sha: null, coverage: null, unresolved: null } + : reviewerEvidence(snapshot, policy, now); + // Thread state comes from GitHub, the marker from the provider. The stricter + // reading wins, and a resolved-or-missing thread never proves a clean review. + if (threads.length && review.state !== 'failure') review = { ...review, state: 'failure', reason: 'review_threads' }; + const blocking = threads.map(id => `thread:${id}`); + if (review.reason === 'review_findings') blocking.push('marker:unresolved_findings'); + const tierOK = risk.tier !== 'R2' || labels.applied !== null; + const allowed = review.state === 'success' && tierOK && template.ok; + let reason; + if (review.state === 'failure') reason = review.reason; + else if (!tierOK) reason = 'clearing_label_required'; + else if (!template.ok) reason = 'pr_template_incomplete'; + else if (review.state === 'pending') reason = 'ai_review_pending'; + else reason = review.reason; + const gateState = !tierOK ? 'failure' + : review.state === 'pending' ? 'pending' + : allowed ? 'success' : 'failure'; + // Two labels, two claims: the gate names which one cleared the high-risk path + // so an agent-cleared merge is visible instead of passing as a human review. + const cleared = labels.applied === 'owner' ? 'cleared by owner-approved' + : labels.applied === 'agent' ? 'cleared by agent-approved, NOT confirmed by the owner' : null; + const gateDescription = gateState === 'success' + ? `${risk.tier}: ${cleared || (review.reason === 'review_not_required' ? 'low-risk prose' : 'review verified')}` + : gateState === 'pending' ? `${risk.tier}: waiting for reviewer` + : `${risk.tier}: ${GATE_TEXT[reason] || REVIEW_TEXT[reason] || reason}`; + const evidenceSha = digest({ + binding: expected, + tier: risk.tier, + mode: policy.mode, + review: { format: review.format, comment_id: review.comment_id, commit_sha: review.commit_sha, + coverage: review.coverage, unresolved: review.unresolved, state: review.state, reason: review.reason }, + threads: (snapshot.threads || []).map(stableThread), + labels: { applied: labels.applied, records: labels.records }, + template: { ok: template.ok, exempt: template.exempt }, + dependabot, + commits: (snapshot.commits || []).map(commit => ({ author: commit.author?.login ?? null, + committer: commit.committer?.login ?? null, verified: commit.commit?.verification?.verified === true })), + }); + return { + policy_version: SCHEMA, + mode: policy.mode, + tier: risk.tier, + rules: risk.rules, + binding: expected, + review: { ...review, description: REVIEW_TEXT[review.reason] }, + template, + labels, + dependabot, + blocking_findings: blocking, + allowed, + reason, + evidence_sha256: evidenceSha, + statuses: [ + { context: 'pr-template', state: template.state, description: template.description }, + { context: 'ai-review', state: review.state, description: REVIEW_TEXT[review.reason] }, + { context: 'risk-gate', state: gateState, description: gateDescription }, + ], + }; +} + +// ---------------------------------------------------------------- template --- + +function completeTemplate(body) { + // Whitespace prevents joining delimiters; an unterminated comment hides the rest. + const text = (body || '').replace(/|$)/g, ' '); + const sections = [...text.matchAll(/^#{1,6}\s+(.+)\r?\n([\s\S]*?)(?=^#{1,6}\s|$(?![\s\S]))/gm)]; + const used = new Set(); + const present = pattern => { + const index = sections.findIndex(([, heading, content], position) => !used.has(position) && pattern.test(heading) + && content.replace(/```[\s\S]*?```/g, '').replace(/[-*]\s*\[[ x]\]/g, '').trim().length > 3 + && !/^\s*(?:TODO|TBD|N\/A|placeholder|\.\.\.|…|Describe the change.*|Which AI tool.*|List the checks.*)\s*$/i.test(content)); + if (index < 0) return false; + used.add(index); + return true; + }; + return present(/\bAI\b|模型|人工智能/i) + && present(/what changed|summary|problem|changes|变更|改动|做了什么/i) + && present(/how verified|verif|validat|test(?:ing| plan)?|验证|测试/i); +} + +module.exports = { + SCHEMA, MODES, CLEARING_LABELS, MARKER_NAMES, REVIEW_TEXT, + sha256Hex, canonical, digest, isSha, + validatePolicy, pathTier, classify, completeTemplate, binding, + stableComment, stableThread, matchesReviewer, matchesThreadAuthor, normalizeLogin, + reviewerEvidence, dependabotEvidence, labelEvidence, templateVerdict, evaluate, +}; diff --git a/pr-governance/scripts/install-gitleaks.sh b/pr-governance/scripts/install-gitleaks.sh new file mode 100644 index 0000000..4b266e1 --- /dev/null +++ b/pr-governance/scripts/install-gitleaks.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +platform="$(uname -s)-$(uname -m)" +case "$platform" in + Linux-x86_64) + target=linux_x64 + checksum=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + ;; + Darwin-x86_64) + target=darwin_x64 + checksum=dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709 + ;; + Darwin-arm64) + target=darwin_arm64 + checksum=b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5 + ;; + *) echo "Unsupported Gitleaks platform" >&2; exit 1 ;; +esac +archive="$(mktemp "${RUNNER_TEMP:?}/gitleaks.XXXXXX")" +trap 'rm -f "$archive"' EXIT +curl --fail --silent --show-error --location --retry 2 \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_${target}.tar.gz" \ + --output "$archive" +if [[ "$platform" == Linux-* ]]; then + echo "$checksum $archive" | sha256sum --check --status +else + echo "$checksum $archive" | shasum -a 256 --check --status +fi +mkdir -p "$RUNNER_TEMP/gitleaks-bin" +tar -xzf "$archive" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks diff --git a/pr-governance/scripts/main.cjs b/pr-governance/scripts/main.cjs new file mode 100644 index 0000000..564bd66 --- /dev/null +++ b/pr-governance/scripts/main.cjs @@ -0,0 +1,66 @@ +'use strict'; + +const gate = require('./gate.cjs'); +const { run, recordLabel } = require('./runner.cjs'); + +const POLICY_PATH = '.github/governance/policy.json'; + +// Account identities belong in protected configuration, not in committed +// policy files, so the owner is resolved from repository or organization +// variables and only falls back to the policy when none is configured. +function configuredOwner() { + const raw = process.env.GOVERNANCE_OWNER_ID; + const login = process.env.GOVERNANCE_OWNER_LOGIN; + if (!raw && !login) return null; + const id = Number(raw); + if (!Number.isSafeInteger(id) || id < 1 || typeof login !== 'string' || !login) { + throw new Error('Incomplete owner configuration'); + } + return { user_id: id, login }; +} + +async function defaultBranch(github, context) { + const known = context.payload?.repository?.default_branch + || context.payload?.pull_request?.base?.ref; + if (known) return known; + const { data } = await github.rest.repos.get(context.repo); + return data.default_branch; +} + +async function loadPolicy(github, context, branch) { + const { data: ref } = await github.rest.repos.getBranch({ ...context.repo, branch }); + const policyCommit = ref.commit?.sha; + if (!gate.isSha(policyCommit)) throw new Error('Unknown policy commit'); + const { data: file } = await github.rest.repos.getContent({ + ...context.repo, path: POLICY_PATH, ref: policyCommit, + }); + if (file.type !== 'file' || file.encoding !== 'base64' || !file.content) { + throw new Error('Missing trusted policy'); + } + let policy; + try { + policy = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')); + } catch { + throw new Error('Trusted policy is not valid JSON'); + } + const owner = configuredOwner(); + if (owner) policy.owner = owner; + gate.validatePolicy(policy); + // Without an owner nobody can ever satisfy owner-approved, so a missing + // configuration must fail the check instead of silently downgrading it. + if (!policy.owner) throw new Error('Owner configuration is required'); + return { policy, policyCommit, policySha: gate.sha256Hex(gate.canonical(policy)) }; +} + +async function runGate({ github, context, core }) { + // Label evidence must not ride on an evaluation a later event can supersede, + // so its own job records it and stops. The evaluate job depends on this one. + if (context.eventName === 'pull_request_target' && context.payload?.action === 'labeled') { + return recordLabel({ github, context, core }); + } + const branch = await defaultBranch(github, context); + const { policy, policyCommit, policySha } = await loadPolicy(github, context, branch); + return run({ github, context, core, policy, policySha, policyCommit, defaultBranch: branch }); +} + +module.exports = { runGate, loadPolicy, defaultBranch }; diff --git a/pr-governance/scripts/nudge.cjs b/pr-governance/scripts/nudge.cjs new file mode 100644 index 0000000..af7408b --- /dev/null +++ b/pr-governance/scripts/nudge.cjs @@ -0,0 +1,78 @@ +'use strict'; + +// Posts one review request per head when the reviewer has published no receipt +// for it. The marker is keyed to the head, so a later push gets its own request +// without repeating this one, and a receipt already present is never overridden. + +const CODEX_LOGIN = /^chatgpt-codex-connector(?:\[bot\])?$/; +const RECEIPT_MARKERS = ['codex-review-state:v1', 'codex-review-receipt:v1']; +const MIN_HEAD_AGE_MS = 10 * 60 * 1000; +const MAX_PULL_REQUESTS = 100; + +const marker = head => ``; + +function receiptFor(comments, head) { + return comments.some(comment => CODEX_LOGIN.test(comment.user?.login || '') + && typeof comment.body === 'string' + && RECEIPT_MARKERS.some(name => comment.body.includes(name)) + && comment.body.includes(head)); +} + +function alreadyNudged(comments, head) { + const needle = marker(head); + return comments.some(comment => typeof comment.body === 'string' && comment.body.includes(needle)); +} + +async function headAge(github, repo, head) { + try { + const { data } = await github.rest.repos.getCommit({ ...repo, ref: head }); + const date = Date.parse(data.commit?.committer?.date || data.commit?.author?.date || ''); + return Number.isFinite(date) ? Date.now() - date : null; + } catch { + return null; + } +} + +async function reviewRequested(github, repo, pr, core) { + const head = pr.head?.sha; + if (!head || pr.draft || pr.state !== 'open') return 'skipped'; + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, issue_number: pr.number, per_page: 100, + }); + if (receiptFor(comments, head)) return 'reviewed'; + if (alreadyNudged(comments, head)) return 'nudged'; + const age = await headAge(github, repo, head); + if (age === null || age < MIN_HEAD_AGE_MS) return 'too-new'; + await github.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body: `@codex review\n\n${marker(head)}\n\nThis head has no review receipt after 10 minutes.`, + }); + core.info(`Requested a review for #${pr.number} at ${head.slice(0, 7)}`); + return 'requested'; +} + +async function run({ github, context, core }) { + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + let pulls; + if (context.payload.pull_request) { + pulls = [context.payload.pull_request]; + } else { + pulls = await github.paginate(github.rest.pulls.list, { + ...repo, state: 'open', per_page: 100, sort: 'updated', direction: 'desc', + }); + if (pulls.length > MAX_PULL_REQUESTS) { + core.info(`Evaluating the ${MAX_PULL_REQUESTS} most recently updated of ${pulls.length} open pull requests`); + pulls = pulls.slice(0, MAX_PULL_REQUESTS); + } + } + + const counts = {}; + for (const pr of pulls) { + const outcome = await reviewRequested(github, repo, pr, core); + counts[outcome] = (counts[outcome] || 0) + 1; + } + core.info(`Review requests: ${Object.entries(counts).map(([key, value]) => `${key}=${value}`).join(' ') || 'none'}`); +} + +module.exports = { run, receiptFor, alreadyNudged, marker }; diff --git a/pr-governance/scripts/runner.cjs b/pr-governance/scripts/runner.cjs new file mode 100644 index 0000000..b9e2444 --- /dev/null +++ b/pr-governance/scripts/runner.cjs @@ -0,0 +1,388 @@ +'use strict'; + +const gate = require('./gate.cjs'); + +// GitHub reports the status author without the bot suffix used in comment +// payloads, so every identity comparison normalises it. +const PUBLISHER = { id: 41898282, login: 'github-actions[bot]' }; +const STATUS_LIMIT = 1000; +const CONTEXTS = ['pr-template', 'ai-review', 'risk-gate']; +const POLICY_PATH = '.github/governance/policy.json'; +const DEPENDABOT_POLICY_PATH = '.github/scripts/dependabot-policy.json'; + +const found = (message, code) => Object.assign(new Error(message), { code }); + +async function selectTargets(github, context, defaultBranch) { + const repo = context.repo; + const event = context.payload || {}; + const openPullRequests = () => github.paginate(github.rest.pulls.list, + { ...repo, state: 'open', base: defaultBranch, per_page: 100 }); + if (context.eventName === 'push' || context.eventName === 'schedule') { + return (await openPullRequests()).map(pull => pull.number); + } + if (context.eventName === 'pull_request_target' || context.eventName === 'pull_request') { + const pull = event.pull_request; + if (!pull) throw new Error('Missing pull request payload'); + // Closing a pull request can end a shared-head collision, so the remaining + // open pull requests are re-evaluated instead of the one that just closed. + if (event.action === 'closed') { + return (await openPullRequests()).filter(item => item.number !== pull.number).map(item => item.number); + } + if (!Number.isSafeInteger(pull.number) || pull.number < 1) throw new Error('Invalid PR number'); + return [pull.number]; + } + if (context.eventName === 'issue_comment') { + if (!event.issue?.pull_request) return []; + if (!Number.isSafeInteger(event.issue.number) || event.issue.number < 1) throw new Error('Invalid PR number'); + return [event.issue.number]; + } + if (context.eventName === 'workflow_run') { + // Reviews on fork and Dependabot pull requests arrive through a + // permissionless signal workflow; only its completion is trusted here. + const run = event.workflow_run; + if (!run) throw new Error('Missing workflow run payload'); + const listed = Array.isArray(run.pull_requests) ? run.pull_requests : []; + const numbers = listed + .map(item => item?.number) + .filter(number => Number.isSafeInteger(number) && number > 0); + if (numbers.length) return [...new Set(numbers)]; + const owner = run.head_repository?.owner?.login; + if (!owner || typeof run.head_branch !== 'string' || !run.head_branch) return []; + const pulls = await github.paginate(github.rest.pulls.list, + { ...repo, state: 'open', base: defaultBranch, head: `${owner}:${run.head_branch}`, per_page: 100 }); + return pulls.map(pull => pull.number); + } + if (context.eventName === 'workflow_dispatch') { + if (context.ref !== `refs/heads/${defaultBranch}`) throw new Error('Dispatch must use the default branch'); + const number = Number(event.inputs?.pr_number); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid PR number'); + return [number]; + } + throw found('Unsupported event', 'UNSUPPORTED_EVENT'); +} + +async function readThreads(github, repo, number) { + const threads = []; + let cursor = null; + do { + // The author of a thread decides whether it blocks, so the first comment is + // part of the query rather than a second round trip per thread. + const data = await github.graphql(`query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + reviewThreads(first:100,after:$cursor) { + nodes { id isResolved isOutdated comments(first:1) { nodes { author { login } } } } + pageInfo { hasNextPage endCursor } + } + } } + }`, { ...repo, number, cursor }); + const page = data?.repository?.pullRequest?.reviewThreads; + if (!page || !Array.isArray(page.nodes) || !page.pageInfo) throw new Error('Incomplete review threads'); + for (const node of page.nodes) { + if (!node?.id || typeof node.isResolved !== 'boolean') throw new Error('Invalid review thread'); + threads.push({ id: node.id, isResolved: node.isResolved, isOutdated: node.isOutdated === true, + author: { login: node.comments?.nodes?.[0]?.author?.login ?? null } }); + } + const next = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; + if (page.pageInfo.hasNextPage && (!next || next === cursor)) throw new Error('Invalid thread page'); + cursor = next; + } while (cursor); + return threads; +} + +async function readDependabotPolicy(github, repo, ref) { + try { + const { data } = await github.rest.repos.getContent({ ...repo, path: DEPENDABOT_POLICY_PATH, ref }); + if (data?.type !== 'file' || typeof data.content !== 'string') return null; + const parsed = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); + if (!parsed || !Array.isArray(parsed.action_files)) return null; + return { action_files: parsed.action_files.filter(path => typeof path === 'string').sort() }; + } catch { + // A missing or unreadable dependency policy grants no exemption. + return null; + } +} + +async function collect(github, context, number, policyCommit) { + const repo = context.repo; + const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); + const params = { ...repo, pull_number: number, per_page: 100 }; + const [files, commits, comments, timeline, statuses, threads, dependabotPolicy] = await Promise.all([ + github.paginate(github.rest.pulls.listFiles, params), + github.paginate(github.rest.pulls.listCommits, params), + github.paginate(github.rest.issues.listComments, { ...repo, issue_number: number, per_page: 100 }), + github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }), + github.paginate(github.rest.repos.listCommitStatusesForRef, { ...repo, ref: pr.head.sha, per_page: 100 }), + readThreads(github, repo, number), + readDependabotPolicy(github, repo, policyCommit), + ]); + return { + repository: `${repo.owner}/${repo.repo}`, + policyCommit, + pr, + files, + commits, + comments, + timeline, + statuses, + threads, + dependabotPolicy, + complete: true, + }; +} + +function snapshotVersion(snapshot) { + // Only security-relevant fields: mergeability, reactions and other churn must + // not invalidate an evaluation, while comment edits still do through the + // timestamp (bodies are never part of a digest). + const pr = snapshot.pr; + return gate.digest({ + repository: snapshot.repository, + policy_commit: snapshot.policyCommit, + pr: { + state: pr.state, + number: pr.number, + body: pr.body, + head: pr.head?.sha, + base: pr.base?.sha, + base_ref: pr.base?.ref, + base_repo: pr.base?.repo?.id ?? null, + labels: (pr.labels || []).map(label => label.name).sort(), + author: { id: pr.user?.id ?? null, login: pr.user?.login ?? null }, + changed_files: pr.changed_files, + commits: pr.commits, + }, + files: snapshot.files, + commits: (snapshot.commits || []).map(commit => ({ + author: commit.author?.login ?? null, + committer: commit.committer?.login ?? null, + verified: commit.commit?.verification?.verified === true, + })), + comments: (snapshot.comments || []).map(gate.stableComment), + threads: (snapshot.threads || []).map(gate.stableThread), + timeline: (snapshot.timeline || []).map(event => ({ id: event.id, event: event.event, + label: event.label?.name ?? null, actor: event.actor?.id ?? null })), + clearing: (snapshot.statuses || []).filter(status => status.context === 'clearing-label') + .map(status => ({ context: status.context, state: status.state, description: status.description })) + .sort((left, right) => (left.description < right.description ? -1 : 1)), + dependabot_policy: snapshot.dependabotPolicy, + }); +} + +// The `labeled` payload is the only authenticated record of which head a +// clearing label was added to, and the payload carries no event id. Matching the +// addition's own timestamp against the timeline names that exact addition, so a +// revoked and re-added label cannot reuse an earlier record. +async function recordLabel({ github, context, core }) { + if (context.eventName !== 'pull_request_target' || context.payload?.action !== 'labeled') return null; + const label = context.payload.label?.name; + if (!gate.CLEARING_LABELS.includes(label)) return null; + const pull = context.payload.pull_request; + if (!pull || pull.state !== 'open' || !pull.head?.sha || !Number.isSafeInteger(pull.number)) return null; + const addedAt = pull.updated_at; + if (!addedAt) return null; + const events = await github.paginate(github.rest.issues.listEvents, + { ...context.repo, issue_number: pull.number, per_page: 100 }); + const matches = events.filter(event => event.event === 'labeled' && event.label?.name === label + && event.created_at === addedAt && Number.isSafeInteger(event.actor?.id)); + // Two additions in the same second cannot be told apart; recording either + // could credit the wrong head, so this delivery records nothing. + if (matches.length !== 1) return null; + const event = matches[0]; + const description = `${label} for PR ${pull.number} at event ${event.id} on this head`; + const target = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.repos.createCommitStatus({ ...context.repo, sha: pull.head.sha, + context: 'clearing-label', state: 'success', description, target_url: target }); + core?.info?.(`Recorded ${label} (event ${event.id}, actor ${event.actor.id}) for PR #${pull.number}`); + return { label, event_id: event.id, actor_id: event.actor.id, actor_login: event.actor.login ?? null, sha: pull.head.sha }; +} + +const collisionEntries = () => CONTEXTS.map(context => ({ + context, state: 'error', description: 'Another open pull request shares this head', +})); + +const errorEntries = description => CONTEXTS.map(context => ({ context, state: 'error', description })); + +// Writes the entries that are not already current, and refuses to spend the last +// status slot of a context on anything but a blocking state. +async function publishAll({ github, repo, head, history, entries, mode, target }) { + // `local` mode is a dry run: the verdict is reported, nothing is claimed. + if (mode === 'local') return []; + const planned = []; + let exhausted = false; + for (const entry of entries) { + const own = history.filter(status => status.context?.toLowerCase() === entry.context); + const description = (mode === 'report-only' ? `report-only: ${entry.description}` : entry.description).slice(0, 140); + const latest = own[0]; + if (latest?.creator?.id === PUBLISHER.id && latest.creator.login === PUBLISHER.login + && latest.state === entry.state && latest.description === description) continue; + // GitHub allows 1000 statuses per commit and context. Keep the last slot for + // a blocking state instead of spending it on a claim. + if (own.length >= STATUS_LIMIT) { exhausted = true; continue; } + if (own.length >= STATUS_LIMIT - 1) { + planned.push({ context: entry.context, state: 'error', description: 'Commit status limit reached; new head required' }); + exhausted = true; + continue; + } + planned.push({ context: entry.context, state: entry.state, description }); + } + const written = []; + for (const entry of planned) { + const { data } = await github.rest.repos.createCommitStatus({ ...repo, sha: head, + context: entry.context, state: entry.state, description: entry.description, target_url: target }); + history.unshift(data); + written.push(data); + } + if (exhausted) throw found('Commit status limit reached; no verdict may be published', 'STATUS_LIMIT'); + return written; +} + +async function publishUnavailable({ github, context, core, number, mode }) { + const { data: pull } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + if (pull.state !== 'open' || !gate.isSha(pull.head.sha)) return; + const history = await github.paginate(github.rest.repos.listCommitStatusesForRef, + { ...context.repo, ref: pull.head.sha, per_page: 100 }); + const target = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + try { + await publishAll({ github, repo: context.repo, head: pull.head.sha, history, + entries: errorEntries('Governance policy unavailable; rerun required'), mode, target }); + } catch (error) { + core?.warning?.(`PR #${number}: ${error.message}`); + } +} + +async function evaluateOne(options) { + const { github, context, core, policy, policySha, policyCommit, defaultBranch, now, number, mode } = options; + const repo = context.repo; + const target = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; + const { data: initial } = await github.rest.pulls.get({ ...repo, pull_number: number }); + if (initial.state !== 'open') return { number, skipped: true }; + const head = initial.head.sha; + let history = []; + + const publish = entries => publishAll({ github, repo, head, history, entries, mode, target }); + + const sharedHead = async () => { + const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); + return open.some(item => item.number !== number && item.head?.sha === head); + }; + + try { + // An unreadable head leaves no place to publish; a non-default base is + // governed by its own branch policy, so both fail closed with error + // statuses rather than claiming anything. + if (!gate.isSha(head)) throw new Error('Unknown head commit'); + if (initial.base.ref !== defaultBranch) throw new Error('Only the default branch is governed'); + if (await sharedHead()) { + const before = await collect(github, context, number, policyCommit); + history = before.statuses; + await publish(collisionEntries()); + throw found('Another open pull request shares this head; statuses would collide', 'SHARED_HEAD'); + } + const before = await collect(github, context, number, policyCommit); + history = before.statuses; + if (before.pr.head.sha !== head) throw new Error('Head changed during collection'); + const dependabot = gate.dependabotEvidence(before, policy); + if (before.pr.base.sha !== policyCommit) { + // A stale base is a stable state, not a failed read: the evidence binds to + // the base it was compared against, so publish an explicit verdict instead + // of an opaque error until the branch is rebased. + const { tier } = gate.classify(before.files, before.pr.changed_files, policy, { dependabot }); + const template = gate.templateVerdict(before, policy, dependabot); + await publish([ + { context: 'pr-template', state: template.state, description: template.description }, + { context: 'ai-review', state: 'failure', description: 'base-outdated: rebase to re-evaluate' }, + { context: 'risk-gate', state: 'failure', description: `${tier}: base-outdated` }, + ]); + // A stale base blocks that pull request through its own statuses; it must + // not fail the sweep, or every base push would go red while reviewed pull + // requests wait to rebase. + core?.warning?.(`PR #${number}: base ${before.pr.base.sha.slice(0, 7)} is behind ${policyCommit.slice(0, 7)}; rebase to evaluate`); + return { number, baseOutdated: true, allowed: false, reason: 'base-outdated', tier, + summary: { pr: number, tier, allowed: false, reason: 'base-outdated', + base_sha: before.pr.base.sha, policy_commit: policyCommit } }; + } + const result = gate.evaluate(before, policy, policySha, now); + const after = await collect(github, context, number, policyCommit); + if (after.pr.head.sha !== head) throw new Error('Head changed during evaluation'); + history = after.statuses; + const { data: branch } = await github.rest.repos.getBranch({ ...repo, branch: defaultBranch }); + if (branch.commit?.sha !== policyCommit) throw new Error('Trusted base changed during evaluation'); + if (after.pr.base.sha !== policyCommit) throw new Error('Pull request base changed during evaluation'); + if (snapshotVersion(before) !== snapshotVersion(after)) throw new Error('Evidence changed during evaluation'); + if (await sharedHead()) { + await publish(collisionEntries()); + throw found('Another open pull request shares this head; statuses would collide', 'SHARED_HEAD'); + } + // Expiry can cross during the readback; never publish a stale decision. + const final = gate.evaluate(after, policy, policySha, now); + if (gate.digest(final) !== gate.digest(result)) throw new Error('Decision changed during evaluation'); + await publish(final.statuses); + return { number, allowed: final.allowed, reason: final.reason, tier: final.tier, summary: final }; + } catch (error) { + // A limit breach already wrote its blocking state; retrying would only + // produce the same refusal. + if (error?.code !== 'STATUS_LIMIT') { + await Promise.allSettled([publish(errorEntries(error?.code === 'SHARED_HEAD' + ? 'Another open pull request shares this head' + : 'Evaluation failed; rerun required'))]); + } + throw error; + } +} + +function writeSummary(core, entries) { + const summary = core?.summary; + if (!summary || !entries.length) return; + summary.addHeading('PR governance verdicts'); + for (const entry of entries) { + summary.addCodeBlock(JSON.stringify(entry.summary, null, 2), 'json'); + } + summary.addRaw('\nRequired checks and branch protection remain the merge constraint. ' + + 'This record only verifies reviewer evidence and label binding.\n'); + summary.write?.(); +} + +async function run({ github, context, core, policy, policySha, policyCommit, defaultBranch, now }) { + const stamp = Number.isFinite(now) ? now : Math.floor(Date.now() / 1000); + const mode = gate.MODES.includes(policy?.mode) ? policy.mode : 'enforce'; + const targets = await selectTargets(github, context, defaultBranch); + const outcomes = []; + const failures = []; + for (const number of targets) { + if (!policy) { + // Policy is read from the trusted base; without it nothing can be claimed. + failures.push({ number, message: 'policy unavailable' }); + await publishUnavailable({ github, context, core, number, mode }); + continue; + } + try { + const outcome = await evaluateOne({ github, context, core, policy, policySha, policyCommit, + defaultBranch, now: stamp, number, mode }); + if (outcome?.skipped) continue; + outcomes.push(outcome); + if (outcome.baseOutdated) continue; + if (!outcome.allowed) failures.push({ number, message: outcome.reason }); + } catch (error) { + // Do not print response bodies: they can contain user input. + failures.push({ number, message: error?.code || error?.name || 'Error' }); + } + } + writeSummary(core, outcomes); + if (!failures.length) return { evaluated: targets.length, failures }; + if (mode === 'report-only') { + core?.warning?.(`${failures.length} pull request(s) would fail governance: ${failures.map(item => `#${item.number}`).join(', ')}`); + return { evaluated: targets.length, failures }; + } + if (mode === 'local') { + core?.info?.(`${failures.length} pull request(s) would fail governance: ${failures.map(item => `#${item.number}`).join(', ')}`); + return { evaluated: targets.length, failures }; + } + // One failure at the end of the sweep, never one per pull request. + core?.setFailed?.(`${failures.length} pull request(s) failed governance: ${failures.map(item => `#${item.number}`).join(', ')}`); + return { evaluated: targets.length, failures }; +} + +module.exports = { + PUBLISHER, STATUS_LIMIT, CONTEXTS, POLICY_PATH, DEPENDABOT_POLICY_PATH, + selectTargets, readThreads, readDependabotPolicy, collect, snapshotVersion, recordLabel, run, +}; diff --git a/pr-governance/scripts/secret_scan.py b/pr-governance/scripts/secret_scan.py new file mode 100644 index 0000000..92bec15 --- /dev/null +++ b/pr-governance/scripts/secret_scan.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Redacted baseline, pull request range and artifact-path scans for Gitleaks 8.30.1.""" +from __future__ import annotations + +import argparse +from collections import Counter +import glob +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path + +IDENTITY_FIELDS = ('RuleID', 'File', 'Commit', 'StartLine', 'Fingerprint') +BASELINE_FIELDS = set(IDENTITY_FIELDS) | {'Secret', 'Match'} +# The scanner, its rules and its pinned binary live in the governance action, so +# the repository under judgement cannot change the rules that judge it. +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / '.gitleaks.toml' +COMMIT = re.compile(r'[0-9a-f]{40}') + + +def identity(finding): + return tuple(finding[field] for field in IDENTITY_FIELDS) + + +def project(finding): + return {**{field: finding[field] for field in IDENTITY_FIELDS}, + 'Secret': 'REDACTED', 'Match': 'REDACTED'} + + +def load_baseline(path): + baseline = json.loads(path.read_text()) + if not isinstance(baseline, list): + raise ValueError('Invalid baseline list') + for finding in baseline: + if set(finding) != BASELINE_FIELDS or any( + finding[field] != 'REDACTED' for field in ('Secret', 'Match') + ): + raise ValueError('Baseline must contain only redacted identity fields') + if not COMMIT.fullmatch(finding['Commit']): + raise ValueError('Baseline must identify an immutable commit') + if not isinstance(finding['StartLine'], int) or finding['StartLine'] < 1: + raise ValueError('Invalid baseline line') + fingerprint = f"{finding['Commit']}:{finding['File']}:{finding['RuleID']}:{finding['StartLine']}" + if finding['Fingerprint'] != fingerprint: + raise ValueError('Baseline fingerprint does not match its identity') + return baseline + + +def reject_source_ignores(target): + # Gitleaks also discovers ignore files in the scan source, independently of + # --gitleaks-ignore-path. Never let unreviewed source fingerprints hide findings. + target = Path(target) + if target.name == '.gitleaksignore': + raise RuntimeError('Source ignore files are not permitted') + + if target.is_file(): + if os.path.lexists(target.parent / '.gitleaksignore'): + raise RuntimeError('Source ignore files are not permitted') + return + + def fail_walk(error): + raise error + + for _, directories, files in os.walk(target, onerror=fail_walk): + if '.gitleaksignore' in directories or '.gitleaksignore' in files: + raise RuntimeError('Source ignore files are not permitted') + + +def git(target, *arguments, capture=False): + # Patch output is only needed for its exit status, and historical blobs are + # not guaranteed to be UTF-8. Decoding them would abort the scan on any + # repository that ever committed a non-UTF-8 file. + try: + return subprocess.run(['git', '-C', str(target), *arguments], + stdout=subprocess.PIPE if capture else subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, encoding='utf-8', errors='replace', check=False) + except OSError: + raise RuntimeError('Git history is unreadable; scan blocked') from None + + +def verify_history(target): + # Gitleaks can report success after its internal Git reader fails. Read the + # complete patch history first, suppressing every source-controlled diagnostic. + try: + root = git(target, 'rev-parse', '--show-toplevel', capture=True) + if root.returncode or Path(root.stdout.strip()).resolve() != Path(target).resolve(): + raise RuntimeError('Git history is unreadable; scan blocked') + except (OSError, ValueError): + raise RuntimeError('Git history is unreadable; scan blocked') from None + for arguments in (('rev-parse', '--verify', 'HEAD^{commit}'), + ('log', '--all', '--full-history', '--diff-merges=first-parent', '-p', '--')): + if git(target, *arguments).returncode: + raise RuntimeError('Git history is unreadable; scan blocked') + + +def verify_range(target, base, head): + if not COMMIT.fullmatch(base or '') or not COMMIT.fullmatch(head or ''): + raise RuntimeError('Git range is incomplete; scan blocked') + verify_history(target) + for revision in (base, head): + if git(target, 'rev-parse', '--verify', '--end-of-options', + f'{revision}^{{commit}}').returncode: + raise RuntimeError('Git range is unreadable; scan blocked') + if git(target, 'log', '--full-history', '-p', '--end-of-options', + f'{base}..{head}', '--').returncode: + raise RuntimeError('Git range is unreadable; scan blocked') + + +def scan(binary, mode, target, report, baseline=None, config=None, log_opts=None): + if mode == 'git': + verify_history(target) + reject_source_ignores(target) + command = [binary, mode, str(target), '--config', str(config or CONFIG), + '--ignore-gitleaks-allow', '--redact', '--no-banner', + '--report-format', 'json', '--report-path', str(report)] + if mode == 'git': + # Plain `git log -p` hides merge diffs, so a credential added only while + # resolving a conflict would never appear in the history scan. + command.append('--log-opts=' + (log_opts or '--all --diff-merges=first-parent')) + if baseline is not None: + command.extend(['--baseline-path', str(baseline)]) + result = subprocess.run(command, capture_output=True, check=False) + if result.returncode not in (0, 1) or not report.is_file(): + raise RuntimeError('Gitleaks failed; no artifact may be uploaded') + findings = json.loads(report.read_text()) + if not isinstance(findings, list) or bool(findings) != bool(result.returncode): + raise RuntimeError('Gitleaks returned an inconsistent result') + return findings + + +def write_summary(path, findings): + # Only aggregate counts may leave the run: a path or commit prefix in the + # artifact is enough to recover the value from history. + path.parent.mkdir(parents=True, exist_ok=True) + counts = Counter(finding['RuleID'] for finding in findings) + path.write_text(json.dumps({'new_findings': len(findings), + 'by_rule': dict(sorted(counts.items()))}, indent=2) + '\n') + + +def history(binary, baseline_path, summary, create=False, root=None, config=None): + root = Path(root or ROOT).resolve() + with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory: + findings = scan(binary, 'git', root, Path(directory) / 'history.json', config=config) + if create: + if baseline_path.exists(): + raise ValueError('Refusing to replace an existing history baseline') + baseline_path.write_text(json.dumps([project(finding) for finding in findings], indent=2) + '\n') + known = Counter(identity(finding) for finding in load_baseline(baseline_path)) + remaining = known.copy() + new_findings = [] + for finding in findings: + key = identity(finding) + if remaining[key] > 0: + remaining[key] -= 1 + else: + new_findings.append(finding) + write_summary(summary, new_findings) + print(f'History scan: {len(new_findings)} new finding(s); {len(known)} baseline identities') + return int(bool(new_findings)) + + +def range_scan(binary, base, head, root=None, config=None): + root = Path(root or ROOT).resolve() + verify_range(root, base, head) + with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory: + findings = scan(binary, 'git', root, Path(directory) / 'range.json', + config=config, log_opts=f'{base}..{head}') + if findings: + print(f'Range scan: {len(findings)} finding(s) in added commits') + else: + print('Range scan passed: no finding in added commits') + return int(bool(findings)) + + +def artifacts(binary, patterns, config=None): + paths = sorted({Path(match) for pattern in patterns.splitlines() if pattern.strip() + for match in glob.glob(pattern.strip(), recursive=True)}) + with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory: + for index, path in enumerate(paths): + findings = scan(binary, 'dir', path, Path(directory) / f'{index}.json', config=config) + if findings: + print('Artifact scan blocked upload: credential finding(s); values suppressed') + for finding in findings: + print(json.dumps({'File': finding['File'], 'RuleID': finding['RuleID']})) + return 1 + print(f'Artifact scan passed: {len(paths)} matching upload path(s)') + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=('history', 'range', 'artifacts')) + parser.add_argument('--gitleaks', default='gitleaks') + parser.add_argument('--root', type=Path, help='repository under judgement') + parser.add_argument('--config', type=Path, help='rules shipped with the governance action') + parser.add_argument('--base') + parser.add_argument('--head') + parser.add_argument('--baseline', type=Path) + parser.add_argument('--summary', type=Path) + parser.add_argument('--create-baseline', action='store_true') + arguments = parser.parse_args() + try: + if arguments.mode == 'artifacts': + return artifacts(arguments.gitleaks, os.environ['ARTIFACT_PATHS'], arguments.config) + if arguments.mode == 'range': + if not arguments.base or not arguments.head: + parser.error('range requires --base and --head') + return range_scan(arguments.gitleaks, arguments.base, arguments.head, + arguments.root, arguments.config) + if arguments.summary is None: + parser.error('history requires --summary') + if arguments.baseline is None: + parser.error('history requires --baseline') + return history(arguments.gitleaks, arguments.baseline, arguments.summary, + arguments.create_baseline, arguments.root, arguments.config) + except (OSError, ValueError, KeyError, TypeError, RuntimeError): + print('Secret scan failed safely; values and raw reports suppressed') + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pr-governance/scripts/test-codex.cjs b/pr-governance/scripts/test-codex.cjs new file mode 100644 index 0000000..7c10fdb --- /dev/null +++ b/pr-governance/scripts/test-codex.cjs @@ -0,0 +1,203 @@ +'use strict'; + +// Reviewer evidence reader. Only the configured reviewer identity is read, and +// the newest marker decides: a clean receipt never outlives a newer bad one. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const gate = require('./gate.cjs'); +const { fixture, stateMarker, receiptMarker, rawComment, thread, HEAD, POLICY_SHA, NOW } = require('./fixtures.cjs'); + +const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW); +const review = f => evaluate(f).review; + +test('a clean receipt-v1 approves the current head', () => { + const result = evaluate(receiptMarker(fixture())); + assert.equal(result.allowed, true); + assert.equal(result.review.format, 'codex-review-receipt:v1'); + assert.equal(result.review.commit_sha, HEAD); + assert.equal(result.review.coverage, 'complete'); + assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'success'); +}); + +test('a clean state-v1 approves the current head', () => { + const result = evaluate(stateMarker(fixture())); + assert.equal(result.allowed, true); + assert.equal(result.review.format, 'codex-review-state:v1'); + assert.equal(result.review.comment_id, 900); +}); + +test('a marker for another commit does not approve this head', () => { + const result = evaluate(receiptMarker(fixture(), { commit_sha: 'f'.repeat(40) })); + assert.equal(result.allowed, false); + assert.equal(result.review.reason, 'review_stale'); + assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'failure'); +}); + +test('an unfinished review is pending, not a failure and not a pass', () => { + const result = evaluate(stateMarker(fixture(), { status: 'in_progress' })); + assert.equal(result.allowed, false); + assert.equal(result.review.state, 'pending'); + assert.equal(result.review.reason, 'review_running'); + assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'pending'); +}); + +test('a missing marker is pending and never a clean review', () => { + const result = evaluate(fixture()); + assert.equal(result.review.reason, 'review_pending'); + assert.equal(result.review.comment_id, null); + assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'pending'); +}); + +test('the newest marker wins when the head was reviewed twice', () => { + const f = receiptMarker(fixture(), { commit_sha: 'f'.repeat(40) }, { updated: -600, id: 901 }); + receiptMarker(f, {}, { updated: -60, id: 902 }); + assert.equal(evaluate(f).allowed, true); + const reverse = receiptMarker(fixture(), {}, { updated: -600, id: 901 }); + receiptMarker(reverse, { commit_sha: 'f'.repeat(40) }, { updated: -60, id: 902 }); + assert.equal(evaluate(reverse).allowed, false); +}); + +test('a newer malformed marker supersedes an older clean receipt', () => { + const f = receiptMarker(fixture(), {}, { updated: -600, id: 901 }); + rawComment(f, '', { updated: -60, id: 902 }); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.review.reason, 'review_malformed'); + assert.equal(result.review.comment_id, 902); +}); + +test('a newer marker that names no commit supersedes an older receipt', () => { + const f = receiptMarker(fixture(), {}, { updated: -600, id: 901 }); + rawComment(f, '', { updated: -60, id: 902 }); + assert.equal(evaluate(f).review.reason, 'review_malformed'); +}); + +test('two markers in one comment are ambiguous and refused', () => { + const f = fixture(); + rawComment(f, `` + + `\n`); + assert.equal(evaluate(f).review.reason, 'review_malformed'); +}); + +test('an unterminated second marker is counted and refused', () => { + const f = fixture(); + rawComment(f, `` + + '\n`, + { user: { id: 999, login: 'impostor[bot]', type: 'Bot' } }); + assert.equal(evaluate(f).review.reason, 'review_pending'); +}); + +test('a marker with the right login but the wrong numeric id is ignored', () => { + const f = fixture(); + rawComment(f, ``, + { user: { id: 1, login: f.policy.reviewer.login, type: 'Bot' } }); + assert.equal(evaluate(f).review.reason, 'review_pending'); +}); + +test('reviewer identity is matched with and without the bot suffix', () => { + const f = fixture(); + rawComment(f, ``, + { user: { id: f.policy.reviewer.user_id, login: f.policy.reviewer.login.replace('[bot]', ''), type: 'Bot' } }); + assert.equal(evaluate(f).allowed, true); +}); + +test('unresolved findings in a receipt block and are named', () => { + for (const unresolved of [true, 3]) { + const result = evaluate(receiptMarker(fixture(), { unresolved_findings: unresolved })); + assert.equal(result.allowed, false); + assert.equal(result.review.reason, 'review_findings'); + assert.deepEqual(result.blocking_findings, ['marker:unresolved_findings']); + } +}); + +test('a resolved receipt finding passes without a blocker', () => { + const result = evaluate(receiptMarker(fixture(), { unresolved_findings: 0 })); + assert.equal(result.allowed, true); + assert.deepEqual(result.blocking_findings, []); +}); + +test('a missing findings flag is not evidence of a clean review', () => { + const receipt = fixture(); + rawComment(receipt, ``); + assert.equal(evaluate(receipt).review.reason, 'review_findings_unknown'); + const state = fixture(); + rawComment(state, ``); + assert.equal(evaluate(state).review.reason, 'review_findings_unknown'); +}); + +test('partial or unknown coverage blocks the verdict', () => { + for (const coverage of ['partial', 'sampled', '']) { + const result = evaluate(receiptMarker(fixture(), { coverage })); + assert.equal(result.allowed, false, coverage); + assert.equal(result.review.reason, 'review_partial', coverage); + } +}); + +test('evidence older than the policy age limit is refused', () => { + const limit = 168 * 3600; + const stale = evaluate(receiptMarker(fixture(), {}, { updated: -limit - 1 })); + assert.equal(stale.review.reason, 'review_expired'); + const fresh = evaluate(receiptMarker(fixture(), {}, { updated: -limit + 1 })); + assert.equal(fresh.allowed, true); + const future = evaluate(receiptMarker(fixture(), {}, { updated: 600 })); + assert.equal(future.allowed, true); +}); + +test('an unresolved thread blocks even when the receipt says clean', () => { + const f = receiptMarker(fixture()); + thread(f, { id: 'PRRT_1' }); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.review.reason, 'review_threads'); + assert.deepEqual(result.blocking_findings, ['thread:PRRT_1']); +}); + +test('marker findings and threads are both listed as blockers', () => { + const f = receiptMarker(fixture(), { unresolved_findings: 2 }); + thread(f, { id: 'PRRT_2' }); + assert.deepEqual(evaluate(f).blocking_findings, ['thread:PRRT_2', 'marker:unresolved_findings']); +}); + +test('a resolved thread and a human thread never block', () => { + const resolved = receiptMarker(fixture()); + thread(resolved, { id: 'PRRT_done', resolved: true }); + assert.equal(evaluate(resolved).allowed, true); + const human = receiptMarker(fixture()); + thread(human, { id: 'PRRT_human', author: 'reviewer-person' }); + assert.equal(evaluate(human).allowed, true); +}); + +test('a thread author id of another account does not block', () => { + const f = receiptMarker(fixture()); + thread(f, { id: 'PRRT_fake' }); + Object.assign(f.snapshot.threads[0].author, { id: 1 }); + assert.equal(evaluate(f).allowed, true); +}); + +test('R0 prose reports no review requirement even with a stale marker', () => { + const f = receiptMarker(fixture({ path: 'README.md' }), { commit_sha: 'f'.repeat(40) }); + const result = evaluate(f); + assert.equal(result.tier, 'R0'); + assert.equal(result.review.reason, 'review_not_required'); + assert.equal(result.allowed, true); +}); + +test('an unresolved thread still blocks R0 prose', () => { + const f = fixture({ path: 'README.md' }); + thread(f, { id: 'PRRT_prose' }); + assert.equal(evaluate(f).allowed, false); +}); + +test('a clean receipt does not clear a high-risk change', () => { + const result = evaluate(receiptMarker(fixture({ path: 'runtime/engine.js' }))); + assert.equal(result.tier, 'R2'); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'clearing_label_required'); +}); diff --git a/pr-governance/scripts/test-gate.cjs b/pr-governance/scripts/test-gate.cjs new file mode 100644 index 0000000..7b9f5f0 --- /dev/null +++ b/pr-governance/scripts/test-gate.cjs @@ -0,0 +1,384 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const gate = require('./gate.cjs'); +const { + fixture, receiptMarker, stateMarker, thread, labelEvent, clearRecord, + HEAD, BASE, POLICY_SHA, NOW, COMPLETE_TEMPLATE, +} = require('./fixtures.cjs'); + +const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW); + +test('sha256 matches node:crypto for ascii, unicode and empty input', () => { + for (const text of ['', 'abc', 'hello world', '中文 🇨🇳 测试', 'x'.repeat(4096), '\u0000\u00ff']) { + assert.equal(gate.sha256Hex(text), createHash('sha256').update(text, 'utf8').digest('hex')); + } +}); + +test('digest is stable under key order and exposes no undefined leaves', () => { + assert.equal(gate.digest({ b: 1, a: [2, { d: 3, c: 4 }] }), gate.digest({ a: [2, { c: 4, d: 3 }], b: 1 })); + assert.match(gate.digest({ omitted: undefined }), /^[a-f0-9]{64}$/); + assert.equal(gate.canonical({ z: 1, y: 'two' }), '{"y":"two","z":1}'); +}); + +test('the shipped default policy validates', () => { + const shipped = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.default.json'), 'utf8')); + assert.deepEqual(gate.validatePolicy(shipped), shipped); + assert.equal(shipped.version, 'pr-governance/v3'); + assert.equal(shipped.mode, 'enforce'); + assert.equal(shipped.reviewer.provider, 'codex'); +}); + +test('the owner is optional and resolved from protected variables', () => { + const shipped = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.default.json'), 'utf8')); + assert.equal(shipped.owner, undefined); + const withOwner = { ...shipped, owner: { user_id: 101, login: 'synthetic-owner' } }; + assert.deepEqual(gate.validatePolicy(withOwner), withOwner); +}); + +test('the schema file lists exactly the fields the evaluator enforces', () => { + const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.schema.json'), 'utf8')); + assert.equal(schema.$id, 'pr-governance/v3'); + assert.equal(schema.additionalProperties, false); + const declared = Object.keys(schema.properties).sort(); + const validated = ['version', 'mode', 'reviewer', 'r0_paths', 'r2_paths', + 'dependabot_template_exemption', 'max_evidence_age_hours'].sort(); + assert.deepEqual(declared, [...validated, 'owner'].sort()); + assert.deepEqual([...schema.required].sort(), validated); + assert.deepEqual([...schema.properties.mode.enum].sort(), ['enforce', 'local', 'report-only']); + assert.deepEqual(schema.properties.reviewer.required.sort(), ['app_id', 'login', 'provider', 'user_id']); + assert.deepEqual(schema.properties.owner.required.sort(), ['login', 'user_id']); +}); + +for (const [name, mutate] of [ + ['wrong schema version', policy => { policy.version = 'pr-governance/v2'; }], + ['unknown mode', policy => { policy.mode = 'advisory'; }], + ['missing reviewer login', policy => { delete policy.reviewer.login; }], + ['extra reviewer field', policy => { policy.reviewer.app_slug = 'synthetic'; }], + ['unknown top level field', policy => { policy.repository = 'example-org/example-repo'; }], + ['non bot reviewer login', policy => { policy.reviewer.login = 'some-user'; }], + ['string owner id', policy => { policy.owner.user_id = '101'; }], + ['unknown owner field', policy => { policy.owner.team = 'synthetic'; }], + ['empty owner login', policy => { policy.owner.login = ''; }], + ['unsupported provider', policy => { policy.reviewer.provider = 'coderabbit'; }], + ['duplicate r2 rule', policy => { policy.r2_paths.push(policy.r2_paths[0]); }], + ['absolute r0 rule', policy => { policy.r0_paths.push('/etc/passwd'); }], + ['escaping r2 rule', policy => { policy.r2_paths.push('../secrets'); }], + ['non boolean dependabot flag', policy => { policy.dependabot_template_exemption = 'yes'; }], + ['zero evidence age', policy => { policy.max_evidence_age_hours = 0; }], + ['excessive evidence age', policy => { policy.max_evidence_age_hours = 100000; }], +]) test(`policy validation rejects ${name}`, () => { + const f = fixture(); + mutate(f.policy); + assert.throws(() => evaluate(f)); +}); + +test('protected paths are R2 and prose allowlisted paths are R0', () => { + const policy = fixture().policy; + for (const file of ['.github/workflows/ci.yml', '.github/scripts/tool.py', '.github/governance/policy.json', + 'config/app.json', 'settings/prod.yaml', 'deploy/release.sh', 'src/trading/engine.js', 'lib/secrets.js', + 'src/credentials.json', 'iam/policy.json', 'runtime/boot.js', 'strategies/momentum.py', 'Dockerfile', + 'infra/main.tf', 'infra/key.pem', 'package.json', 'requirements-dev.txt', 'CODEOWNERS', 'AGENTS.md', + 'scripts/verify-release.sh', '.env.production']) { + assert.equal(gate.pathTier(file, policy), 'R2', file); + } + assert.equal(gate.pathTier('README.md', policy), 'R0'); + assert.equal(gate.pathTier('docs/prose/notes.md', policy), 'R0'); +}); + +test('unlisted prose and ordinary code default to R1', () => { + const policy = fixture().policy; + assert.equal(gate.pathTier('docs/architecture.md', policy), 'R1'); + assert.equal(gate.pathTier('src/view.js', policy), 'R1'); + assert.equal(gate.pathTier('README.txt', policy), 'R1'); +}); + +test('path traversal and absolute paths are refused', () => { + const policy = fixture().policy; + for (const file of ['../README.md', '/etc/passwd', 'a/./b', 'a//b', 'a\\b', '']) { + assert.throws(() => gate.pathTier(file, policy), /Unknown path/, file); + } +}); + +test('the highest tier of a change wins', () => { + const f = fixture({ files: [ + { filename: 'README.md', status: 'modified', sha: 'd'.repeat(40), additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n-old\n+new prose' }, + { filename: 'src/login.js', status: 'modified', sha: 'e'.repeat(40), additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n-a\n+b' }, + ] }); + const result = evaluate(f); + assert.equal(result.tier, 'R2'); + assert.deepEqual(result.rules.map(rule => rule.tier), ['R0', 'R2']); +}); + +test('a rename keeps both the old and the new path in the tier', () => { + const f = fixture({ path: 'docs/prose/moved.md' }); + Object.assign(f.snapshot.files[0], { status: 'renamed', previous_filename: 'iam/policy.json', + additions: 0, deletions: 0, patch: '' }); + assert.equal(evaluate(f).tier, 'R2'); +}); + +test('prose that carries code or a sensitive keyword is not low risk', () => { + const code = fixture({ path: 'README.md' }); + code.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+`npm install`'; + assert.equal(evaluate(code).tier, 'R1'); + const secret = fixture({ path: 'README.md' }); + secret.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+Rotate the deployment credential'; + assert.equal(evaluate(secret).tier, 'R2'); + const chinese = fixture({ path: 'README.md' }); + chinese.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+更新部署流程'; + assert.equal(evaluate(chinese).tier, 'R2'); +}); + +test('index-only prose edits still need a reviewer', () => { + const f = fixture({ path: 'README.md' }); + Object.assign(f.snapshot.files[0], { status: 'added', additions: 1, deletions: 0, + patch: '@@ -0,0 +1 @@\n+A brand new paragraph.' }); + assert.equal(evaluate(f).tier, 'R1'); +}); + +for (const [name, mutate] of [ + ['incomplete file list', s => { s.pr.changed_files = 2; }], + ['missing patch', s => { delete s.files[0].patch; }], + ['truncated patch', s => { s.files[0].additions = 2; }], + ['binary diff', s => { Object.assign(s.files[0], { patch: '', additions: 0, deletions: 0 }); }], + ['missing old rename path', s => { s.files[0].status = 'renamed'; }], + ['unknown file state', s => { s.files[0].status = 'unknown'; }], + ['duplicate file', s => { s.files.push({ ...s.files[0] }); s.pr.changed_files = 2; }], + ['missing file sha', s => { s.files[0].sha = ''; }], + ['incomplete commit coverage', s => { s.pr.commits = 2; }], + ['incomplete snapshot', s => { s.complete = false; }], + ['closed pull request', s => { s.pr.state = 'closed'; }], + ['unknown head', s => { s.pr.head.sha = ''; }], + ['missing evidence lists', s => { delete s.threads; }], +]) test(`${name} fails closed`, () => { + const f = fixture(); + mutate(f.snapshot); + assert.throws(() => evaluate(f)); +}); + +test('the file API cap is refused even when the count matches', () => { + const f = fixture({ path: 'src/view.js' }); + f.snapshot.files = Array.from({ length: 3000 }, (_, index) => ({ + ...f.snapshot.files[0], filename: `src/${index}.js`, + })); + f.snapshot.pr.changed_files = 3000; + assert.throws(() => evaluate(f), /Incomplete file list/); +}); + +test('an unknown policy digest is refused', () => { + const f = fixture(); + assert.throws(() => gate.evaluate(f.snapshot, f.policy, 'not-a-digest', NOW)); +}); + +test('a clean receipt passes R1 and publishes three success statuses', () => { + const f = receiptMarker(fixture()); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.reason, 'review_complete'); + assert.equal(result.tier, 'R1'); + assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [ + ['pr-template', 'success'], ['ai-review', 'success'], ['risk-gate', 'success'], + ]); + assert.equal(result.binding.head_sha, HEAD); + assert.equal(result.binding.base_sha, BASE); + assert.equal(result.binding.policy_sha256, POLICY_SHA); + assert.match(result.evidence_sha256, /^[a-f0-9]{64}$/); +}); + +test('R0 prose needs no reviewer evidence but still reports a verdict', () => { + const result = evaluate(fixture({ path: 'README.md' })); + assert.equal(result.tier, 'R0'); + assert.equal(result.allowed, true); + assert.equal(result.reason, 'review_not_required'); + assert.equal(result.review.state, 'success'); + assert.deepEqual(result.statuses.map(entry => entry.context), ['pr-template', 'ai-review', 'risk-gate']); +}); + +test('R1 without any reviewer marker stays pending', () => { + const result = evaluate(fixture()); + assert.equal(result.allowed, false); + assert.equal(result.review.state, 'pending'); + assert.equal(result.reason, 'ai_review_pending'); + assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [ + ['pr-template', 'success'], ['ai-review', 'pending'], ['risk-gate', 'pending'], + ]); +}); + +test('high risk needs a clearing label even when the review is clean', () => { + const f = receiptMarker(fixture({ path: 'config/app.json' })); + const result = evaluate(f); + assert.equal(result.tier, 'R2'); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'clearing_label_required'); + assert.equal(result.statuses.at(-1).state, 'failure'); +}); + +test('a bound owner label clears high risk and names the human approval', () => { + const f = receiptMarker(fixture({ path: 'config/app.json' })); + const record = labelEvent(f); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.labels.applied, 'owner'); + assert.match(result.statuses.at(-1).description, /cleared by owner-approved/); +}); + +test('a bound agent label clears high risk and names the agent approval', () => { + const f = receiptMarker(fixture({ path: 'config/app.json' })); + const record = labelEvent(f, { name: 'agent-approved', actor: { id: 999, login: 'agent' } }); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.labels.applied, 'agent'); + assert.equal(result.labels.records['agent-approved'].actor_id, 999); +}); + +test('an incomplete template blocks the verdict', () => { + const f = receiptMarker(fixture()); + f.snapshot.pr.body = '## What changed\nSomething.\n## How verified\nTests.'; + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'pr_template_incomplete'); + assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [ + ['pr-template', 'failure'], ['ai-review', 'success'], ['risk-gate', 'failure'], + ]); +}); + +test('an unresolved reviewer thread blocks a clean receipt', () => { + const f = receiptMarker(fixture()); + thread(f, { id: 'PRRT_kwDO_block' }); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.review.state, 'failure'); + assert.equal(result.review.reason, 'review_threads'); + assert.deepEqual(result.blocking_findings, ['thread:PRRT_kwDO_block']); + assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'failure'); +}); + +test('stable thread ids are part of the evidence digest', () => { + const first = fixture(); + thread(first, { id: 'PRRT_one', resolved: true }); + const second = fixture(); + thread(second, { id: 'PRRT_two', resolved: true }); + assert.notEqual(evaluate(first).evidence_sha256, evaluate(second).evidence_sha256); + const resolved = fixture(); + thread(resolved, { id: 'PRRT_one', resolved: true }); + assert.equal(evaluate(first).evidence_sha256, evaluate(resolved).evidence_sha256); +}); + +test('comment prose never enters the evidence digest', () => { + const first = fixture(); + receiptMarker(first, {}, { updated: -60 }); + const second = fixture(); + receiptMarker(second, {}, { updated: -60 }); + second.snapshot.comments[0].body = '\n\nCompletely different prose that must not be hashed.\n'; + assert.equal(evaluate(first).evidence_sha256, evaluate(second).evidence_sha256); +}); + +test('template detection accepts English and Chinese headings', () => { + assert.equal(gate.completeTemplate(COMPLETE_TEMPLATE), true); + assert.equal(gate.completeTemplate('## AI 使用情况\n使用了智能体。\n## 变更\n修复门禁。\n## 测试\n离线测试通过。'), true); + assert.equal(gate.completeTemplate('## 模型\nCodex。\n## 这个 PR 做了什么\n修复门禁。\n## 怎么验证的\n离线测试通过。'), true); + assert.equal(gate.completeTemplate('## AI\nCodex.\n## Summary\nGate fix.\n## Verification\nOffline tests passed.'), true); + assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nTODO\n## How verified\nOffline tests passed.'), false); + assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nplaceholder\n## How verified\nTBD'), false); + assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nValid detail.\n## How verified\n\n '), false); + assert.equal(gate.completeTemplate('## Main changes and tests\nEverything is complete.'), false); + assert.equal(gate.completeTemplate(''), false); +}); + +test('template comments cannot manufacture or hide headings', () => { + assert.equal(gate.completeTemplate('## AI\nCodex.\nhat changed\nDetail.\n## How verified\nDetail.'), false); + assert.equal(gate.completeTemplate('## AI\nCodex.\n\n## How verified\nDetail.'), false); +}); + +test('dependabot detection requires bot author, web-flow committer and a verified commit', () => { + const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' }, + commit: { verification: { verified: true } } }]; + const identity = login => ({ id: 49699333, login, type: 'Bot' }); + const build = mutate => { + const f = fixture({ commits, dependabotPolicy: { action_files: ['.github/workflows/ci.yml'] } }); + f.snapshot.pr.user = identity('dependabot[bot]'); + mutate?.(f.snapshot); + return f; + }; + assert.equal(gate.dependabotEvidence(build().snapshot, build().policy).detected, true); + for (const mutate of [ + snapshot => { snapshot.commits[0].committer.login = 'author'; }, + snapshot => { snapshot.commits[0].commit.verification.verified = false; }, + snapshot => { snapshot.commits[0].author.login = 'author'; }, + snapshot => { snapshot.pr.user = { id: 7, login: 'author' }; }, + snapshot => { snapshot.commits = []; snapshot.pr.commits = 0; }, + ]) { + const f = build(mutate); + assert.equal(gate.dependabotEvidence(f.snapshot, f.policy).detected, false); + } +}); + +test('an allowlisted action bump is not high risk, a different workflow still is', () => { + const bump = path => ({ filename: path, status: 'modified', sha: 'd'.repeat(40), additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n- - uses: actions/checkout@v5\n+ - uses: actions/checkout@v6' }); + const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' }, + commit: { verification: { verified: true } } }]; + const build = files => { + const f = fixture({ files, commits }); + f.snapshot.pr.user = { id: 49699333, login: 'dependabot[bot]', type: 'Bot' }; + f.snapshot.dependabotPolicy = { action_files: ['.github/workflows/ci.yml'] }; + return f; + }; + const allowed = build([bump('.github/workflows/ci.yml')]); + assert.equal(evaluate(allowed).tier, 'R1'); + const other = build([bump('.github/workflows/release.yml')]); + assert.equal(evaluate(other).tier, 'R2'); + const swapped = build([{ filename: '.github/workflows/ci.yml', status: 'modified', sha: 'd'.repeat(40), + additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n- - uses: actions/checkout@v5\n+ - uses: evil/action@v6' }]); + assert.equal(evaluate(swapped).tier, 'R2'); + const extra = build([{ filename: '.github/workflows/ci.yml', status: 'modified', sha: 'd'.repeat(40), + additions: 2, deletions: 1, + patch: '@@ -1,2 +1,2 @@\n- - uses: actions/checkout@v5\n+ - uses: actions/checkout@v6\n+ if: false' }]); + assert.equal(evaluate(extra).tier, 'R2'); + const human = build([bump('.github/workflows/ci.yml')]); + human.snapshot.pr.user = { id: 7, login: 'author' }; + assert.equal(evaluate(human).tier, 'R2'); +}); + +test('Dependabot template exemption follows the policy flag', () => { + const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' }, + commit: { verification: { verified: true } } }]; + const build = flag => { + const f = fixture({ commits, body: 'Automated dependency bump.', policy: policy => { + policy.dependabot_template_exemption = flag; + } }); + f.snapshot.pr.user = { id: 49699333, login: 'dependabot[bot]', type: 'Bot' }; + return receiptMarker(f); + }; + assert.equal(evaluate(build(true)).template.exempt, true); + assert.equal(evaluate(build(true)).allowed, true); + assert.equal(evaluate(build(false)).template.ok, false); + assert.equal(evaluate(build(false)).allowed, false); +}); + +test('a pull request author never influences the verdict outside Dependabot detection', () => { + for (const login of ['author', 'agent', 'someone-else']) { + const f = receiptMarker(fixture({ path: 'config/app.json' })); + f.snapshot.pr.user = { id: 11, login }; + assert.equal(evaluate(f).allowed, false); + assert.equal(evaluate(f).reason, 'clearing_label_required'); + } +}); + +test('the mode is carried into the result without changing the verdict', () => { + for (const mode of ['enforce', 'local', 'report-only']) { + const f = receiptMarker(fixture(), {}, {}); + f.policy.mode = mode; + assert.equal(evaluate(f).mode, mode); + assert.equal(evaluate(f).allowed, true); + } +}); diff --git a/pr-governance/scripts/test-labels.cjs b/pr-governance/scripts/test-labels.cjs new file mode 100644 index 0000000..a41536e --- /dev/null +++ b/pr-governance/scripts/test-labels.cjs @@ -0,0 +1,267 @@ +'use strict'; + +// Label evidence. A label lives on the pull request, not on a commit, so the +// only label that counts is one whose authenticated timeline event is still the +// newest event for that label and whose head-bound record says so. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const gate = require('./gate.cjs'); +const runner = require('./runner.cjs'); +const { fixture, receiptMarker, labelEvent, clearRecord, HEAD, POLICY_SHA, NOW } = require('./fixtures.cjs'); + +const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW); +const highRisk = () => receiptMarker(fixture({ path: 'config/settings.json' })); + +test('an owner label added by the owner and bound to the head clears high risk', () => { + const f = highRisk(); + const record = labelEvent(f); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.labels.applied, 'owner'); + assert.deepEqual(result.labels.records['owner-approved'], { event_id: record.id, + actor_id: f.policy.owner.user_id, actor_login: f.policy.owner.login }); +}); + +test('an owner-approved label from another actor never counts', () => { + const f = highRisk(); + const record = labelEvent(f, { actor: { id: 5, login: 'author' } }); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.labels.applied, null); + assert.equal(result.reason, 'clearing_label_required'); +}); + +test('an owner login with the wrong numeric id never counts', () => { + const f = highRisk(); + const record = labelEvent(f, { actor: { id: 5, login: f.policy.owner.login } }); + clearRecord(f, record); + assert.equal(evaluate(f).labels.applied, null); +}); + +test('an agent label counts from any actor and records who added it', () => { + for (const actor of [{ id: 5, login: 'author' }, { id: 4242, login: 'some-agent[bot]' }]) { + const f = highRisk(); + const record = labelEvent(f, { name: 'agent-approved', actor }); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.labels.applied, 'agent'); + assert.deepEqual(result.labels.records['agent-approved'], { event_id: record.id, + actor_id: actor.id, actor_login: actor.login }); + assert.match(result.statuses.at(-1).description, /cleared by agent-approved, NOT confirmed by the owner/); + } +}); + +test('a label without a head-bound record never clears high risk', () => { + const f = highRisk(); + labelEvent(f); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.labels.applied, null); +}); + +test('a record for a different event does not revive a re-added label', () => { + const f = highRisk(); + const first = labelEvent(f, { id: 5000 }); + clearRecord(f, first); + labelEvent(f, { id: 5001, event: 'unlabeled' }); + const second = labelEvent(f, { id: 5002 }); + assert.equal(evaluate(f).labels.applied, null); + clearRecord(f, second); + assert.equal(evaluate(f).labels.applied, 'owner'); +}); + +test('a revoked label stays revoked even with its old record present', () => { + const f = highRisk(); + const record = labelEvent(f); + clearRecord(f, record); + labelEvent(f, { id: 6000, event: 'unlabeled' }); + assert.equal(evaluate(f).labels.applied, null); + assert.equal(evaluate(f).allowed, false); +}); + +test('a label event that is not the newest for that label is ignored', () => { + const f = highRisk(); + const stale = labelEvent(f, { id: 4999, name: 'agent-approved', actor: { id: 5, login: 'author' } }); + clearRecord(f, stale); + labelEvent(f, { id: 5001, name: 'agent-approved', event: 'unlabeled' }); + assert.equal(evaluate(f).labels.applied, null); +}); + +test('a record that names another pull request does not bind', () => { + const f = highRisk(); + const record = labelEvent(f); + clearRecord(f, record, { description: `${record.name} for PR 7 at event ${record.id} on this head` }); + assert.equal(evaluate(f).labels.applied, null); +}); + +test('a failing clearing-label status does not bind', () => { + const f = highRisk(); + const record = labelEvent(f); + clearRecord(f, record); + f.snapshot.statuses[0].state = 'failure'; + assert.equal(evaluate(f).labels.applied, null); +}); + +test('a label left on the pull request without any timeline event does not bind', () => { + const f = highRisk(); + f.snapshot.pr.labels = [{ name: 'owner-approved' }]; + f.snapshot.statuses.push({ context: 'clearing-label', state: 'success', + description: `owner-approved for PR 42 at event 1 on this head` }); + assert.equal(evaluate(f).labels.applied, null); +}); + +test('a bound owner label outranks a bound agent label on the same head', () => { + const f = highRisk(); + const owner = labelEvent(f, { id: 5000, name: 'owner-approved' }); + clearRecord(f, owner); + const agent = labelEvent(f, { id: 5001, name: 'agent-approved', actor: { id: 5, login: 'author' } }); + clearRecord(f, agent); + assert.equal(evaluate(f).labels.applied, 'owner'); +}); + +test('a bound agent label still clears high risk when the owner label is unbound', () => { + const f = highRisk(); + labelEvent(f, { id: 5000, name: 'owner-approved' }); + const agent = labelEvent(f, { id: 5001, name: 'agent-approved', actor: { id: 5, login: 'author' } }); + clearRecord(f, agent); + const result = evaluate(f); + assert.equal(result.allowed, true); + assert.equal(result.labels.applied, 'agent'); +}); + +test('no label bypasses the review requirement', () => { + const f = fixture({ path: 'config/settings.json' }); + const record = labelEvent(f); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'ai_review_pending'); + assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'pending'); + assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'pending'); +}); + +test('no label bypasses an unresolved reviewer thread', () => { + const f = highRisk(); + const record = labelEvent(f); + clearRecord(f, record); + f.snapshot.threads.push({ id: 'PRRT_open', isResolved: false, isOutdated: false, + author: { login: f.policy.reviewer.login } }); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.review.reason, 'review_threads'); +}); + +test('no label bypasses the pull request template', () => { + const f = highRisk(); + f.snapshot.pr.body = 'Nothing useful here.'; + const record = labelEvent(f); + clearRecord(f, record); + const result = evaluate(f); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'pr_template_incomplete'); + assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'failure'); +}); + +test('low risk needs no label, so an unbound label changes nothing', () => { + const withLabel = receiptMarker(fixture({ path: 'src/view.js' })); + labelEvent(withLabel); + const withoutLabel = receiptMarker(fixture({ path: 'src/view.js' })); + assert.equal(evaluate(withLabel).allowed, true); + assert.equal(evaluate(withLabel).evidence_sha256, evaluate(withoutLabel).evidence_sha256); +}); + +// ------------------------------------------------------------ record step --- + +function recordContext(mutate = () => {}) { + const context = { + eventName: 'pull_request_target', + repo: { owner: 'example-org', repo: 'example-repo' }, + payload: { + action: 'labeled', + label: { name: 'owner-approved' }, + pull_request: { number: 42, state: 'open', head: { sha: HEAD }, updated_at: '2026-01-01T00:00:00Z' }, + }, + serverUrl: 'https://github.example', + runId: 7, + }; + mutate(context); + return context; +} + +function recordMock(timeline) { + const statuses = []; + const github = { + paginate: async () => timeline, + rest: { + issues: { listEvents: 'events' }, + repos: { createCommitStatus: async input => { statuses.push(input); return { data: input }; } }, + }, + }; + const infos = []; + return { github, core: { info: message => infos.push(message) }, statuses, infos }; +} + +const event = (overrides = {}) => ({ id: 5000, event: 'labeled', label: { name: 'owner-approved' }, + actor: { id: 20649888, login: 'owner' }, created_at: '2026-01-01T00:00:00Z', ...overrides }); + +test('the record step writes the exact head-bound description', async () => { + const m = recordMock([event()]); + const record = await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }); + assert.equal(record.event_id, 5000); + assert.deepEqual(m.statuses, [{ + owner: 'example-org', + repo: 'example-repo', + sha: HEAD, + context: 'clearing-label', + state: 'success', + description: 'owner-approved for PR 42 at event 5000 on this head', + target_url: 'https://github.example/example-org/example-repo/actions/runs/7', + }]); + assert.match(m.infos[0], /actor 20649888/); +}); + +test('the record step accepts an agent label and records its actor', async () => { + const context = recordContext(context => { context.payload.label = { name: 'agent-approved' }; }); + const m = recordMock([event({ label: { name: 'agent-approved' }, actor: { id: 77, login: 'agent' } })]); + const record = await runner.recordLabel({ github: m.github, context, core: m.core }); + assert.equal(record.actor_id, 77); + assert.equal(m.statuses[0].description, 'agent-approved for PR 42 at event 5000 on this head'); +}); + +test('the record step ignores everything that is not a clearing label addition', async () => { + const cases = [ + recordContext(context => { context.payload.label = { name: 'documentation' }; }), + recordContext(context => { context.payload.action = 'unlabeled'; }), + recordContext(context => { context.eventName = 'pull_request'; }), + recordContext(context => { context.payload.pull_request.state = 'closed'; }), + recordContext(context => { context.payload.pull_request.head = {}; }), + recordContext(context => { context.payload.pull_request.updated_at = null; }), + ]; + for (const context of cases) { + const m = recordMock([event()]); + assert.equal(await runner.recordLabel({ github: m.github, context, core: m.core }), null); + assert.deepEqual(m.statuses, []); + } +}); + +test('an ambiguous same-second addition records nothing', async () => { + const m = recordMock([event(), event({ id: 5001 })]); + assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null); + assert.deepEqual(m.statuses, []); +}); + +test('an addition whose timestamp does not match any event records nothing', async () => { + const m = recordMock([event({ created_at: '2026-01-01T00:00:09Z' })]); + assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null); + assert.deepEqual(m.statuses, []); +}); + +test('a label event without an actor cannot be recorded', async () => { + const m = recordMock([event({ actor: {} })]); + assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null); + assert.deepEqual(m.statuses, []); +}); diff --git a/pr-governance/scripts/test-main.cjs b/pr-governance/scripts/test-main.cjs new file mode 100644 index 0000000..1a465f7 --- /dev/null +++ b/pr-governance/scripts/test-main.cjs @@ -0,0 +1,64 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const main = require('./main.cjs'); + +const POLICY = { + version: 'pr-governance/v3', + mode: 'enforce', + reviewer: { provider: 'codex', app_id: 1144995, user_id: 199175422, + login: 'chatgpt-codex-connector[bot]' }, + r0_paths: [], + r2_paths: [], + dependabot_template_exemption: true, + max_evidence_age_hours: 168, +}; + +const githubFor = policy => ({ + rest: { + repos: { + get: async () => ({ data: { default_branch: 'main' } }), + getBranch: async () => ({ data: { commit: { sha: 'a'.repeat(40) } } }), + getContent: async () => ({ data: { type: 'file', encoding: 'base64', + content: Buffer.from(JSON.stringify(policy)).toString('base64') } }), + }, + }, +}); + +const context = { repo: { owner: 'example-org', repo: 'example-repo' }, payload: {} }; + +async function load(policy, environment) { + const saved = { ...process.env }; + Object.assign(process.env, environment); + try { + return await main.loadPolicy(githubFor(policy), context, 'main'); + } finally { + delete process.env.GOVERNANCE_OWNER_ID; + delete process.env.GOVERNANCE_OWNER_LOGIN; + Object.assign(process.env, saved); + } +} + +test('owner configuration replaces a committed owner', async () => { + const { policy } = await load({ ...POLICY, owner: { user_id: 1, login: 'stale' } }, + { GOVERNANCE_OWNER_ID: '42', GOVERNANCE_OWNER_LOGIN: 'owner' }); + assert.deepEqual(policy.owner, { user_id: 42, login: 'owner' }); +}); + +test('a policy without owner is valid when the variables are set', async () => { + const { policy, policySha } = await load(POLICY, + { GOVERNANCE_OWNER_ID: '42', GOVERNANCE_OWNER_LOGIN: 'owner' }); + assert.deepEqual(policy.owner, { user_id: 42, login: 'owner' }); + assert.match(policySha, /^[0-9a-f]{64}$/); +}); + +test('a policy without owner and without variables fails closed', async () => { + await assert.rejects(load(POLICY, {})); +}); + +test('an incomplete owner configuration fails closed', async () => { + await assert.rejects(load(POLICY, { GOVERNANCE_OWNER_ID: '42' }), /owner configuration/); + await assert.rejects(load(POLICY, + { GOVERNANCE_OWNER_ID: '0', GOVERNANCE_OWNER_LOGIN: 'owner' }), /owner configuration/); +}); diff --git a/pr-governance/scripts/test-nudge.cjs b/pr-governance/scripts/test-nudge.cjs new file mode 100644 index 0000000..cc1fb17 --- /dev/null +++ b/pr-governance/scripts/test-nudge.cjs @@ -0,0 +1,77 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { run, marker } = require('./nudge.cjs'); + +const HEAD = 'a'.repeat(40); +const OLD = new Date(Date.now() - 60 * 60 * 1000).toISOString(); +const core = { info() {} }; + +function harness({ comments = [], headDate = OLD, draft = false, state = 'open' } = {}) { + const posted = []; + const github = { + paginate: async (fn, params) => (fn === github.rest.pulls.list ? [{ + number: 7, draft, state, head: { sha: HEAD, ref: 'feat' }, + }] : comments), + rest: { + pulls: { list: Symbol('list') }, + issues: { + listComments: Symbol('listComments'), + createComment: async ({ body }) => posted.push(body), + }, + repos: { + getCommit: async () => ({ data: { commit: { committer: { date: headDate } } } }), + }, + }, + }; + const context = { repo: { owner: 'o', repo: 'r' }, payload: {} }; + return { github, context, posted }; +} + +test('requests a review for an old head with no receipt', async () => { + const h = harness(); + await run({ ...h, core }); + assert.equal(h.posted.length, 1); + assert.match(h.posted[0], /@codex review/); + assert.match(h.posted[0], /governance-nudge:a{40}/); +}); + +test('does not repeat the request for the same head', async () => { + const h = harness({ comments: [{ user: { login: 'someone' }, body: marker(HEAD) }] }); + await run({ ...h, core }); + assert.equal(h.posted.length, 0); +}); + +test('does not request a review twice when a receipt exists', async () => { + const h = harness({ + comments: [{ + user: { login: 'chatgpt-codex-connector[bot]' }, + body: ``, + }], + }); + await run({ ...h, core }); + assert.equal(h.posted.length, 0); +}); + +test('accepts the legacy receipt marker', async () => { + const h = harness({ + comments: [{ user: { login: 'chatgpt-codex-connector' }, body: `codex-review-receipt:v1 ${HEAD}` }], + }); + await run({ ...h, core }); + assert.equal(h.posted.length, 0); +}); + +test('waits for a fresh head', async () => { + const h = harness({ headDate: new Date().toISOString() }); + await run({ ...h, core }); + assert.equal(h.posted.length, 0); +}); + +test('skips drafts and closed pull requests', async () => { + for (const options of [{ draft: true }, { state: 'closed' }]) { + const h = harness(options); + await run({ ...h, core }); + assert.equal(h.posted.length, 0); + } +}); diff --git a/pr-governance/scripts/test-runner.cjs b/pr-governance/scripts/test-runner.cjs new file mode 100644 index 0000000..5bbf3e7 --- /dev/null +++ b/pr-governance/scripts/test-runner.cjs @@ -0,0 +1,527 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const gate = require('./gate.cjs'); +const runner = require('./runner.cjs'); +const { fixture, receiptMarker, labelEvent, clearRecord, HEAD, BASE, POLICY_SHA, NOW } = require('./fixtures.cjs'); + +const POLICY = fixture().policy; + +function mock(f, options = {}) { + const statuses = []; + const failures = []; + const warnings = []; + const infos = []; + const calls = []; + const summary = []; + const state = { + pull: structuredClone(f.snapshot.pr), + files: f.snapshot.files, + commits: f.snapshot.commits, + comments: f.snapshot.comments, + timeline: f.snapshot.timeline, + statuses: f.snapshot.statuses, + threads: f.snapshot.threads, + open: options.open || [{ number: f.snapshot.pr.number, head: { sha: f.snapshot.pr.head.sha } }], + branch: options.branch || BASE, + dependabotPolicy: options.dependabotPolicy ?? f.snapshot.dependabotPolicy, + }; + const rest = { + pulls: { + get: async () => ({ data: structuredClone(state.pull) }), + list: async () => state.open, + listFiles: async () => state.files, + listCommits: async () => state.commits, + }, + issues: { + listComments: async () => state.comments, + listEvents: async () => state.timeline, + }, + repos: { + listCommitStatusesForRef: async () => state.statuses, + getBranch: async () => ({ data: { commit: { sha: state.branch } } }), + getContent: async ({ path, ref }) => { + calls.push({ method: 'getContent', params: { path, ref } }); + if (path !== runner.DEPENDABOT_POLICY_PATH) { + return Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })); + } + if (!state.dependabotPolicy) { + return Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })); + } + return { data: { type: 'file', encoding: 'base64', + content: Buffer.from(JSON.stringify(state.dependabotPolicy)).toString('base64') } }; + }, + createCommitStatus: async input => { + const data = { ...input, creator: { id: 41898282, login: 'github-actions[bot]' } }; + statuses.push(data); + state.statuses.unshift(data); + return { data }; + }, + }, + }; + const names = new Map([[rest.pulls.list, 'pulls'], [rest.pulls.listFiles, 'files'], + [rest.pulls.listCommits, 'commits'], [rest.issues.listComments, 'comments'], + [rest.issues.listEvents, 'events'], [rest.repos.listCommitStatusesForRef, 'statuses']]); + const github = { + rest, + paginate: async (method, params) => { + const name = names.get(method) || 'unknown'; + calls.push({ method: name, params }); + if (name === 'pulls') return structuredClone(state.open); + if (name === 'files') return structuredClone(state.files); + if (name === 'commits') return structuredClone(state.commits); + if (name === 'comments') return structuredClone(state.comments); + if (name === 'events') return structuredClone(state.timeline); + if (name === 'statuses') return structuredClone(state.statuses); + throw new Error(`Unexpected paginate: ${name}`); + }, + graphql: async () => ({ + repository: { pullRequest: { reviewThreads: { + nodes: structuredClone(state.threads), pageInfo: { hasNextPage: false, endCursor: null }, + } } }, + }), + }; + const core = { + setFailed: message => failures.push(message), + warning: message => warnings.push(message), + info: message => infos.push(message), + summary: { + addHeading: text => { summary.push(text); return core.summary; }, + addCodeBlock: text => { summary.push(text); return core.summary; }, + addRaw: text => { summary.push(text); return core.summary; }, + write: async () => {}, + }, + }; + const context = { + repo: { owner: 'example-org', repo: 'example-repo' }, + eventName: options.eventName || 'pull_request_target', + payload: options.payload || { action: 'opened', pull_request: { number: f.snapshot.pr.number } }, + ref: options.ref || `refs/heads/${options.branch || 'main'}`, + serverUrl: 'https://github.example', + runId: 123, + }; + return { github, context, core, statuses, failures, warnings, infos, calls, summary, state }; +} + +const runMock = (m, overrides = {}) => runner.run({ github: m.github, context: m.context, core: m.core, + policy: POLICY, policySha: POLICY_SHA, policyCommit: BASE, defaultBranch: 'main', now: NOW, ...overrides }); + +const states = m => m.statuses.map(status => [status.context, status.state]); + +// ------------------------------------------------------------- targeting --- + +test('a single pull request event evaluates only that pull request', async () => { + const m = mock(fixture()); + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]); + assert.deepEqual(m.calls, []); +}); + +test('push and schedule sweep every open pull request', async () => { + for (const eventName of ['push', 'schedule']) { + const m = mock(fixture(), { eventName, open: [{ number: 4 }, { number: 5 }] }); + m.context.payload = {}; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [4, 5]); + } +}); + +test('a large sweep is never truncated', async () => { + const open = Array.from({ length: 40 }, (_, index) => ({ number: index + 1 })); + const m = mock(fixture(), { eventName: 'schedule', open }); + m.context.payload = {}; + assert.equal((await runner.selectTargets(m.github, m.context, 'main')).length, 40); +}); + +test('a closed pull request re-evaluates the remaining open ones', async () => { + const m = mock(fixture(), { open: [{ number: 42 }, { number: 43 }] }); + m.context.payload = { action: 'closed', pull_request: { number: 42 } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [43]); +}); + +test('a comment on an issue that is not a pull request evaluates nothing', async () => { + const m = mock(fixture(), { eventName: 'issue_comment' }); + m.context.payload = { issue: { number: 8 } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []); + m.context.payload = { issue: { number: 8, pull_request: {} } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [8]); +}); + +test('a review relay evaluates the pull requests named by the run', async () => { + const m = mock(fixture(), { eventName: 'workflow_run' }); + m.context.payload = { workflow_run: { pull_requests: [{ number: 42 }, { number: 7 }, { number: 42 }] } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42, 7]); + m.context.payload = { workflow_run: { pull_requests: [{ number: 0 }, { number: 'x' }] } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []); +}); + +test('a review relay without pull request entries falls back to the head branch', async () => { + const m = mock(fixture(), { eventName: 'workflow_run', open: [{ number: 42 }] }); + m.context.payload = { workflow_run: { pull_requests: [], head_repository: { owner: { login: 'example-org' } }, head_branch: 'feature' } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]); + assert.equal(m.calls.at(-1).params.head, 'example-org:feature'); + m.context.payload = { workflow_run: { pull_requests: [], head_branch: 'feature' } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []); + m.context.payload = {}; + await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /workflow run payload/); +}); + +test('dispatch validates the number and the default branch', async () => { + const m = mock(fixture(), { eventName: 'workflow_dispatch' }); + m.context.payload = { inputs: { pr_number: '42' } }; + assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]); + m.context.payload.inputs.pr_number = '1; rm -rf /'; + await assert.rejects(runner.selectTargets(m.github, m.context, 'main')); + m.context.payload.inputs.pr_number = '42'; + m.context.ref = 'refs/heads/feature'; + await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /default branch/); +}); + +test('an unsupported event fails instead of guessing a target', async () => { + const m = mock(fixture(), { eventName: 'watch' }); + m.context.payload = {}; + await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /Unsupported event/); +}); + +test('review threads paginate to the last page and refuse a repeating cursor', async () => { + const m = mock(fixture()); + let page = 0; + m.github.graphql = async (query, params) => { + page++; + // `Actor` has no databaseId field; asking for one makes every evaluation fail. + assert.doesNotMatch(query, /databaseId/); + assert.equal(params.cursor, page === 1 ? null : 'cursor1'); + return { repository: { pullRequest: { reviewThreads: { + nodes: [{ id: `thread-${page}`, isResolved: page === 1, comments: { nodes: [{ author: { login: 'agent' } }] } }], + pageInfo: { hasNextPage: page === 1, endCursor: page === 1 ? 'cursor1' : null }, + } } } }; + }; + const threads = await runner.readThreads(m.github, m.context.repo, 42); + assert.equal(threads.length, 2); + assert.equal(threads[1].author.login, 'agent'); + m.github.graphql = async () => ({ repository: { pullRequest: { reviewThreads: { + nodes: [], pageInfo: { hasNextPage: true, endCursor: null }, + } } } }); + await assert.rejects(runner.readThreads(m.github, m.context.repo, 42), /page/); +}); + +test('every collection endpoint is paginated with the maximum page size', async () => { + const m = mock(fixture()); + await runner.collect(m.github, m.context, 42, BASE); + assert.deepEqual([...new Set(m.calls.filter(call => call.method !== 'getContent').map(call => call.method))].sort(), + ['comments', 'commits', 'events', 'files', 'statuses']); + assert.ok(m.calls.every(call => call.method === 'getContent' || call.params.per_page === 100)); +}); + +test('the dependency policy is read from the trusted base only', async () => { + const m = mock(fixture(), { dependabotPolicy: { action_files: ['.github/workflows/ci.yml'] } }); + const snapshot = await runner.collect(m.github, m.context, 42, BASE); + assert.deepEqual(snapshot.dependabotPolicy, { action_files: ['.github/workflows/ci.yml'] }); + const reads = m.calls.filter(call => call.method === 'getContent'); + assert.deepEqual(reads.map(call => call.params), [{ path: runner.DEPENDABOT_POLICY_PATH, ref: BASE }]); +}); + +// -------------------------------------------------------------- verdicts --- + +test('a reviewed pull request publishes three success statuses and fails nothing', async () => { + const f = receiptMarker(fixture()); + const m = mock(f); + await runMock(m); + assert.deepEqual(states(m), [['pr-template', 'success'], ['ai-review', 'success'], ['risk-gate', 'success']]); + assert.ok(m.statuses.every(status => status.sha === HEAD)); + assert.equal(m.failures.length, 0); + assert.match(m.summary.join('\n'), /"reason": "review_complete"/); +}); + +test('an incomplete template fails the run once, after publishing', async () => { + const f = receiptMarker(fixture()); + f.snapshot.pr.body = 'Nothing useful.'; + const m = mock(f); + await runMock(m); + assert.deepEqual(states(m), [['pr-template', 'failure'], ['ai-review', 'success'], ['risk-gate', 'failure']]); + assert.equal(m.failures.length, 1); + assert.match(m.failures[0], /#42/); +}); + +test('an unchanged sweep appends no statuses and reports nothing', async () => { + const f = receiptMarker(fixture()); + const first = mock(f); + await runMock(first); + assert.equal(first.statuses.length, 3); + for (let index = 0; index < 3; index++) { + const next = mock(f); + next.context.runId += index + 1; + await runMock(next); + assert.equal(next.statuses.length, 0); + assert.equal(next.failures.length, 0); + } +}); + +test('a matching status from another publisher is replaced, not trusted', async () => { + const f = receiptMarker(fixture()); + await runMock(mock(f)); + for (const status of f.snapshot.statuses) status.creator = { id: 1, login: 'author' }; + const m = mock(f); + await runMock(m); + assert.equal(m.statuses.length, 3); +}); + +test('a closed pull request publishes nothing', async () => { + const f = fixture(); + f.snapshot.pr.state = 'closed'; + const m = mock(f); + await runMock(m); + assert.equal(m.statuses.length, 0); + assert.equal(m.failures.length, 0); +}); + +test('a non default base branch is refused', async () => { + const f = fixture(); + f.snapshot.pr.base.ref = 'release'; + const m = mock(f); + await runMock(m); + assert.ok(m.statuses.some(status => status.state === 'error')); + assert.equal(m.failures.length, 1); +}); + +// ---------------------------------------------------------- base outdated --- + +test('a stale base publishes a base-outdated verdict without failing the sweep', async () => { + const f = receiptMarker(fixture()); + f.snapshot.pr.base.sha = 'e'.repeat(40); + const m = mock(f); + await runMock(m); + assert.deepEqual(states(m), [['pr-template', 'success'], ['ai-review', 'failure'], ['risk-gate', 'failure']]); + const verdict = m.statuses.find(status => status.context === 'risk-gate'); + assert.equal(verdict.description, 'R1: base-outdated'); + assert.equal(m.failures.length, 0); + assert.equal(m.warnings.length, 1); + assert.match(m.warnings[0], /rebase/); + const summary = JSON.parse(m.summary.find(entry => entry.trim().startsWith('{'))); + assert.equal(summary.reason, 'base-outdated'); + assert.equal(summary.base_sha, 'e'.repeat(40)); + assert.equal(summary.policy_commit, BASE); + assert.equal(summary.tier, 'R1'); + assert.equal(summary.allowed, false); +}); + +test('a stale base still reports an incomplete template', async () => { + const f = receiptMarker(fixture()); + f.snapshot.pr.base.sha = 'e'.repeat(40); + f.snapshot.pr.body = 'Nothing useful.'; + const m = mock(f); + await runMock(m); + assert.deepEqual(states(m), [['pr-template', 'failure'], ['ai-review', 'failure'], ['risk-gate', 'failure']]); + assert.equal(m.failures.length, 0); +}); + +test('one stale pull request does not stop the sweep from evaluating the rest', async () => { + const f = receiptMarker(fixture()); + const other = '9'.repeat(40); + const m = mock(f, { eventName: 'push', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: other } }] }); + m.context.payload = {}; + const pull = structuredClone(m.state.pull); + m.github.rest.pulls.get = async input => ({ + data: input.pull_number === 42 + ? { ...structuredClone(pull), base: { ...pull.base, sha: 'e'.repeat(40) } } + : { ...structuredClone(pull), head: { sha: other } }, + }); + const result = await runMock(m); + // The stale pull request is a stable verdict, not a failure; the sweep still + // evaluated the second one and failed only for it. + assert.equal(m.failures.length, 1); + assert.match(m.failures[0], /#43/); + assert.doesNotMatch(m.failures[0], /#42/); + assert.ok(m.statuses.some(status => status.description === 'R1: base-outdated')); + assert.deepEqual([...new Set(m.statuses.map(status => status.sha))].sort(), [other, HEAD].sort()); + assert.deepEqual(result.failures.map(item => item.number), [43]); +}); + +// ------------------------------------------------------------ shared head --- + +test('a shared head publishes error statuses and never a pass', async () => { + const f = receiptMarker(fixture()); + const m = mock(f, { open: [{ number: 42, head: { sha: HEAD } }, { number: 99, head: { sha: HEAD } }] }); + const result = await runMock(m); + assert.deepEqual(states(m), [['pr-template', 'error'], ['ai-review', 'error'], ['risk-gate', 'error']]); + assert.ok(m.statuses.every(status => /shares this head/.test(status.description))); + assert.equal(m.failures.length, 1); + assert.deepEqual(result.failures.map(item => item.message), ['SHARED_HEAD']); +}); + +test('a sibling that appears during evaluation still blocks the pass', async () => { + const f = receiptMarker(fixture()); + const m = mock(f); + let lists = 0; + const list = m.github.paginate; + m.github.paginate = async (method, params) => { + if (method === m.github.rest.pulls.list) { + lists++; + if (lists === 2) { + m.state.open = [{ number: 42, head: { sha: HEAD } }, { number: 77, head: { sha: HEAD } }]; + } + } + return list(method, params); + }; + await runMock(m); + assert.ok(m.statuses.some(status => status.state === 'error')); + assert.ok(m.statuses.every(status => status.state !== 'success')); +}); + +// ------------------------------------------------------------ status limit --- + +test('a context with 998 statuses still publishes a real verdict', async () => { + const f = receiptMarker(fixture()); + f.snapshot.statuses = Array.from({ length: 998 }, () => ({ context: 'risk-gate', state: 'success', + description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } })); + const m = mock(f); + await runMock(m); + assert.equal(m.statuses.find(status => status.context === 'risk-gate').state, 'success'); + assert.equal(m.failures.length, 0); +}); + +test('the last status slot is reserved for a blocking state', async () => { + const f = receiptMarker(fixture()); + f.snapshot.statuses = Array.from({ length: 999 }, () => ({ context: 'risk-gate', state: 'success', + description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } })); + const m = mock(f); + const result = await runMock(m); + const writes = m.statuses.filter(status => status.context === 'risk-gate'); + assert.equal(writes.length, 1); + assert.equal(writes[0].state, 'error'); + assert.match(writes[0].description, /status limit/); + assert.equal(m.failures.length, 1); + assert.deepEqual(result.failures.map(item => item.message), ['STATUS_LIMIT']); +}); + +test('an exhausted context reports failure and never claims publication', async () => { + const f = receiptMarker(fixture()); + f.snapshot.statuses = Array.from({ length: 1000 }, () => ({ context: 'risk-gate', state: 'success', + description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } })); + const m = mock(f); + await runMock(m); + assert.equal(m.statuses.filter(status => status.context === 'risk-gate').length, 0); + assert.equal(m.failures.length, 1); + assert.equal(f.snapshot.statuses.find(status => status.context === 'risk-gate').state, 'success'); +}); + +// ------------------------------------------------------------- integrity --- + +for (const field of ['head', 'base', 'body', 'evidence', 'thread', 'labels', 'policy']) { + test(`a concurrent ${field} change during evaluation publishes only errors`, async () => { + const f = receiptMarker(fixture()); + const m = mock(f); + let reads = 0; + const get = m.github.rest.pulls.get; + m.github.rest.pulls.get = async () => { + if (++reads === 3) { + if (field === 'head') m.state.pull.head = { sha: 'e'.repeat(40) }; + if (field === 'base') m.state.pull.base = { ...m.state.pull.base, sha: 'e'.repeat(40) }; + if (field === 'body') m.state.pull.body = 'changed during evaluation'; + if (field === 'evidence') f.snapshot.comments[0].updated_at = new Date((NOW + 30) * 1000).toISOString(); + if (field === 'thread') f.snapshot.threads.push({ id: 'PRRT_late', isResolved: false, author: { login: 'chatgpt-codex-connector' } }); + if (field === 'labels') m.state.pull.labels = [{ name: 'owner-approved' }]; + if (field === 'policy') m.state.branch = 'f'.repeat(40); + } + return get(); + }; + await runMock(m); + assert.ok(m.statuses.some(status => status.state === 'error'), 'an error status must be published'); + assert.ok(m.statuses.every(status => status.state !== 'success'), 'no success may be published'); + assert.equal(m.failures.length, 1); + }); +} + +test('an unavailable API publishes errors and never a success', async () => { + const m = mock(fixture()); + m.github.paginate = async () => { throw new Error('API unavailable'); }; + await runMock(m); + assert.ok(m.statuses.some(status => status.state === 'error')); + assert.ok(m.statuses.every(status => status.state !== 'success')); + assert.equal(m.failures.length, 1); +}); + +test('a policy that cannot be read marks every target and fails', async () => { + const f = receiptMarker(fixture()); + const m = mock(f); + const policy = gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW); + assert.equal(policy.allowed, true); + const result = await runMock(m, { policy: null, policySha: null }); + assert.deepEqual(states(m), [['pr-template', 'error'], ['ai-review', 'error'], ['risk-gate', 'error']]); + assert.ok(m.statuses.every(status => /policy unavailable/i.test(status.description))); + assert.equal(m.failures.length, 1); + assert.deepEqual(result.failures.map(item => item.message), ['policy unavailable']); +}); + +// ------------------------------------------------------------------ modes --- + +test('local mode evaluates and claims nothing', async () => { + const f = receiptMarker(fixture()); + const policy = { ...POLICY, mode: 'local' }; + const m = mock(f); + await runMock(m, { policy }); + assert.deepEqual(m.statuses, []); + assert.equal(m.failures.length, 0); + assert.match(m.summary.join('\n'), /allowed/); +}); + +test('report only mode publishes advisory statuses and never fails the run', async () => { + const f = receiptMarker(fixture()); + f.snapshot.pr.body = 'Nothing useful.'; + const m = mock(f); + await runMock(m, { policy: { ...POLICY, mode: 'report-only' } }); + assert.equal(m.statuses.length, 3); + assert.ok(m.statuses.every(status => status.description.startsWith('report-only: '))); + assert.equal(m.failures.length, 0); + assert.equal(m.warnings.length, 1); +}); + +// -------------------------------------------------------------- disclosure --- + +test('no comment body is echoed into a status or the summary', async () => { + const f = receiptMarker(fixture()); + const marker = 'SECRET-PROSE-THAT-MUST-NOT-APPEAR'; + f.snapshot.comments[0].body += `\n${marker}\n`; + const m = mock(f); + await runMock(m); + const exposed = JSON.stringify([m.statuses, m.summary, m.failures, m.warnings, m.infos]); + assert.equal(exposed.includes(marker), false); +}); + +test('labels bound to a stale head are reported in the summary, not trusted', async () => { + const f = receiptMarker(fixture({ path: 'config/settings.json' })); + const record = labelEvent(f); + clearRecord(f, record); + const m = mock(f); + await runMock(m); + assert.equal(m.statuses.find(status => status.context === 'risk-gate').state, 'success'); + assert.match(m.summary.join('\n'), /cleared by owner-approved/); +}); + +test('the runner reports every failing pull request once at the end of a sweep', async () => { + const f = fixture(); + const m = mock(f, { eventName: 'schedule', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: HEAD } }] }); + m.context.payload = {}; + const evaluated = new Set(); + const get = m.github.rest.pulls.get; + m.github.rest.pulls.get = async input => { evaluated.add(input.pull_number); return get(input); }; + await runMock(m); + assert.deepEqual([...evaluated].sort(), [42, 43]); + assert.equal(m.failures.length, 1); + assert.match(m.failures[0], /#42, #43/); +}); + +test('a failing status publication does not abort later pull requests', async () => { + const f = fixture(); + const m = mock(f, { eventName: 'schedule', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: HEAD } }] }); + m.context.payload = {}; + const evaluated = new Set(); + const get = m.github.rest.pulls.get; + m.github.rest.pulls.get = async input => { evaluated.add(input.pull_number); return get(input); }; + m.github.rest.repos.createCommitStatus = async input => { + m.statuses.push(input); + throw new Error('statuses unavailable'); + }; + await runMock(m); + assert.deepEqual([...evaluated].sort(), [42, 43]); + assert.equal(m.failures.length, 1); +}); diff --git a/pr-governance/scripts/test_secret_scan.py b/pr-governance/scripts/test_secret_scan.py new file mode 100644 index 0000000..c537cf5 --- /dev/null +++ b/pr-governance/scripts/test_secret_scan.py @@ -0,0 +1,465 @@ +import importlib.util +import contextlib +import io +import json +import os +import secrets +import shutil +import string +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('secret_scan', Path(__file__).with_name('secret_scan.py')) +SCANNER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SCANNER) +BINARY = os.environ.get('GITLEAKS_BIN', 'gitleaks') + + +def fake_value(length=32): + return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(length)) + + +class SecretScanTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.output = self.root / 'report.json' + self.source = self.root / 'payload' + self.source.mkdir() + + def detect(self, text, filename='credentials.properties'): + (self.source / filename).write_text(text) + return SCANNER.scan(BINARY, 'dir', self.source, self.output) + + def suppression_repository(self): + subprocess.run(['git', 'init', '--quiet', str(self.source)], check=True, + capture_output=True) + self.git('config', 'user.name', 'Synthetic Test') + self.git('config', 'user.email', 'fixture@example.invalid') + (self.source / 'clean.txt').write_text('clean fixture\n') + self.git('add', '.') + self.git('commit', '--quiet', '-m', 'test: clean fixture') + return self.git('rev-parse', 'HEAD') + + def git(self, *arguments): + return subprocess.run(['git', '-C', str(self.source), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + def suppressed_payload(self, suppression, mode): + self.source = self.root / ('payload-' + mode) + self.source.mkdir() + base = self.suppression_repository() + payload = self.source / 'credentials.properties' + payload.write_text('password=' + fake_value() + '\n') + if suppression == 'comment': + payload.write_text(payload.read_text().rstrip() + ' # gitleaks:allow\n') + self.git('add', '.') + self.git('commit', '--quiet', '-m', 'test: synthetic credential') + if suppression == 'ignore': + findings = SCANNER.scan(BINARY, mode, self.source, self.output) + self.assertTrue(findings) + (self.source / '.gitleaksignore').write_text( + '\n'.join(row['Fingerprint'] for row in findings) + '\n') + self.git('add', '.gitleaksignore') + self.git('commit', '--quiet', '-m', 'test: source suppression') + return base + + def test_helper_ignores_allow_comments(self): + for mode in ('dir', 'git'): + with self.subTest(mode=mode): + self.suppressed_payload('comment', mode) + self.assertTrue(SCANNER.scan(BINARY, mode, self.source, self.output)) + + def test_helper_rejects_source_ignore(self): + for mode in ('dir', 'git'): + with self.subTest(mode=mode): + self.suppressed_payload('ignore', mode) + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, mode, self.source, self.output) + (self.source / '.gitleaksignore').unlink() + + def test_ignore_rejection_is_redacted_at_artifact_sink(self): + value = fake_value() + nested = self.source / 'nested' + nested.mkdir() + (nested / '.gitleaksignore').write_text(value) + output = io.StringIO() + with patch('sys.argv', ['secret_scan.py', 'artifacts', '--gitleaks', BINARY]), \ + patch.dict(os.environ, {'ARTIFACT_PATHS': str(self.source)}), \ + contextlib.redirect_stdout(output): + self.assertEqual(SCANNER.main(), 2) + self.assertNotIn(value, output.getvalue()) + self.assertNotIn(str(self.source), output.getvalue()) + + def range_scan(self, base, head=None): + return SCANNER.range_scan(BINARY, base, head or self.git('rev-parse', 'HEAD'), self.source) + + def test_range_scan_blocks_allow_comment(self): + base = self.suppressed_payload('comment', 'git') + self.assertEqual(self.range_scan(base), 1) + + def test_range_scan_blocks_source_ignore(self): + base = self.suppressed_payload('ignore', 'git') + with self.assertRaises(RuntimeError): + self.range_scan(base) + + def test_range_scan_clean_control(self): + base = self.suppression_repository() + (self.source / 'clean.txt').write_text('another clean fixture\n') + self.git('add', '.') + self.git('commit', '--quiet', '-m', 'test: clean addition') + self.assertEqual(self.range_scan(base), 0) + + def test_range_scan_unknown_base_fails(self): + self.suppressed_payload('comment', 'git') + with self.assertRaises(RuntimeError): + self.range_scan('f' * 40) + + def test_range_scan_empty_range_passes(self): + base = self.suppression_repository() + self.assertEqual(self.range_scan(base), 0) + + def test_range_scan_survives_non_utf8_history(self): + base = self.suppression_repository() + (self.source / 'legacy.txt').write_bytes(b'\xd2\xbb\xb6\xce\xce\xc4\xb1\xbe\n') + self.git('add', '.') + self.git('commit', '--quiet', '-m', 'test: legacy encoding') + self.assertEqual(self.range_scan(base), 0) + + def test_range_scan_unknown_head_fails(self): + base = self.suppression_repository() + with self.assertRaises(RuntimeError): + self.range_scan(base, 'f' * 40) + + def test_missing_blob_blocks_history_and_range(self): + base = self.suppressed_payload('comment', 'git') + # Both tips resolve, but reading the complete patch must fail. + blob = self.git('rev-parse', 'HEAD:credentials.properties') + (self.source / '.git' / 'objects' / blob[:2] / blob[2:]).unlink() + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', self.source, self.output) + with self.assertRaises(RuntimeError): + self.range_scan(base) + + def test_helper_history_git_failure_blocks(self): + (self.source / 'credentials.properties').write_text('password=' + fake_value()) + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', self.source, self.output) + + def test_history_cli_git_failure_blocks(self): + value = fake_value() + (self.source / 'credentials.properties').write_text('password=' + value) + shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', self.source / '.gitleaks.toml') + baseline = self.root / 'baseline.json' + baseline.write_text('[]') + output = io.StringIO() + with patch.object(SCANNER, 'ROOT', self.source), \ + patch('sys.argv', ['scan', 'history', '--gitleaks', BINARY, + '--baseline', str(baseline), '--summary', + str(self.root / 'summary.json')]), \ + contextlib.redirect_stdout(output): + self.assertNotEqual(SCANNER.main(), 0) + self.assertNotIn(value, output.getvalue()) + self.assertNotIn(str(self.source), output.getvalue()) + + def test_history_cannot_inherit_parent_repository(self): + self.suppression_repository() + nested = self.source / 'nested' + nested.mkdir() + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', nested, self.output) + + def test_missing_history_source_blocks(self): + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', self.source / 'absent', self.output) + + def test_history_without_head_blocks(self): + subprocess.run(['git', 'init', '--quiet', str(self.source)], check=True, + capture_output=True) + with self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', self.source, self.output) + + def test_helper_history_enumeration_failure_blocks(self): + self.suppression_repository() + result = subprocess.run + + def fail_history(command, **kwargs): + if command[0] == 'git' and 'log' in command: + return subprocess.CompletedProcess(command, 128) + return result(command, **kwargs) + + with patch.object(SCANNER.subprocess, 'run', side_effect=fail_history), \ + self.assertRaises(RuntimeError): + SCANNER.scan(BINARY, 'git', self.source, self.output) + + def test_history_clean_control(self): + self.suppression_repository() + self.assertEqual(SCANNER.scan(BINARY, 'git', self.source, self.output), []) + + def test_mongodb_protocols_are_detected_without_keywords(self): + for protocol in ('mongodb', 'mongodb+srv'): + with self.subTest(protocol=protocol): + findings = self.detect(f'{protocol}://fixture:{fake_value()}@example.invalid/db') + self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings}) + + def test_alibaba_access_key_secret_is_detected(self): + for name in ('ALIBABA_ACCESS_KEY_SECRET', 'accessKeySecret', 'access_key_secret', + 'access-key', 'accessKey'): + with self.subTest(name=name): + findings = self.detect(name + '=' + fake_value(30)) + self.assertIn('alibaba-access-key-secret', {row['RuleID'] for row in findings}) + + def test_configuration_credentials_are_detected(self): + for name in ('jwt.secret', 'wechat.appsecret', 'request.validate.token', + 'PASSWORD', 'passwd', 'access-key', 'aliyunAccessKey'): + with self.subTest(name=name): + findings = self.detect(f'{name}={fake_value()}') + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_yaml_and_env_assignments_are_detected(self): + for filename, assignment in (('config.yaml', ' appsecret: "'), ('.env', 'export TOKEN="')): + with self.subTest(filename=filename): + findings = self.detect(assignment + fake_value() + '"', filename) + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_clean_placeholders_pass(self): + self.assertEqual(self.detect('jwt.secret=${JWT_SECRET}\npassword=\n' + 'token=${LONG_TOKEN_VARIABLE}\ntoken=${_TOKEN_1}\n' + 'token=your-token-placeholder\npassword=xxxxxxxxxxxxxxxx\n'), []) + + def test_punctuation_in_password_is_detected(self): + findings = self.detect('password=' + fake_value(16) + '&$*()!<>') + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_placeholder_with_literal_text_is_detected(self): + for value in ('${X}' + fake_value(), fake_value() + '${X}', + '${LONG_TOKEN_VARIABLE}' + fake_value(), + '${LONG_TOKEN_VARIABLE};' + fake_value(), + '${LONG_TOKEN_VARIABLE}#' + fake_value(), + '${LONG_TOKEN_VARIABLE:-' + fake_value() + '}', + '${LONG_TOKEN_VARIABLE}${OTHER_VARIABLE}'): + findings = self.detect('token=' + value) + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_alibaba_access_key_id_is_not_a_secret_assignment(self): + for name in ('accessKeyId', 'access_key_id', 'ALIBABA_ACCESS_KEY_ID'): + with self.subTest(name=name): + findings = self.detect(name + '=' + fake_value(30)) + rules = {row['RuleID'] for row in findings} + self.assertNotIn('alibaba-access-key-secret', rules) + self.assertNotIn('configuration-secret-value', rules) + + def test_short_low_entropy_password_is_detected(self): + for length in (8, 12, 15): + with self.subTest(length=length): + value = (''.join(secrets.SystemRandom().sample(string.ascii_lowercase, 2)) * length)[:length] + findings = self.detect('password=' + value) + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_junit_xml_credentials_block_artifacts(self): + for prefix in ('', + 'TEST-fixture.xml') + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.xml')), 1) + self.assertNotIn(value, output.getvalue()) + + def test_junit_xml_placeholder_passes(self): + self.assertEqual(self.detect('', 'TEST-fixture.xml'), []) + + def test_history_baseline_counts_same_identity_findings(self): + repository = self.root / 'repository' + subprocess.run(['git', 'init', '--quiet', str(repository)], check=True) + + def git(*arguments): + return subprocess.run(['git', '-C', str(repository), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + git('config', 'user.name', 'Synthetic Test') + git('config', 'user.email', 'fixture@example.invalid') + shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml') + # Different matches on the same line receive the same Gitleaks identity. + (repository / 'credentials.txt').write_text(' '.join( + f'mongodb://fixture:{fake_value()}@example.invalid/db' for _ in range(2))) + git('add', '.') + git('commit', '--quiet', '-m', 'test: same-line synthetic credentials') + baseline = repository / '.gitleaks-baseline.json' + summary = self.root / 'summary.json' + with patch.object(SCANNER, 'ROOT', repository): + findings = SCANNER.scan(BINARY, 'git', repository, self.output) + matches = [row for row in findings if row['RuleID'] == 'mongodb-userinfo-password'] + self.assertEqual(len(matches), 2) + self.assertEqual(SCANNER.identity(matches[0]), SCANNER.identity(matches[1])) + baseline.write_text(json.dumps([SCANNER.project(row) for row in findings])) + self.assertEqual(SCANNER.history(BINARY, baseline, summary), 0) + # Simulate a baseline approved before a rule exposed the second match. + findings.remove(matches[0]) + baseline.write_text(json.dumps([SCANNER.project(row) for row in findings])) + self.assertEqual(SCANNER.history(BINARY, baseline, summary), 1) + rows = json.loads(summary.read_text()) + self.assertEqual(rows, {'new_findings': 1, 'by_rule': {'mongodb-userinfo-password': 1}}) + + def test_history_scan_includes_merge_commit_diffs(self): + repository = self.root / 'repository' + subprocess.run(['git', 'init', '--quiet', '-b', 'main', str(repository)], check=True) + subprocess.run(['git', '-C', str(repository), 'config', 'user.name', 'Synthetic Test'], check=True) + subprocess.run(['git', '-C', str(repository), 'config', 'user.email', 'fixture@example.invalid'], check=True) + shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml') + payload = repository / 'config.txt' + + def git(*arguments): + return subprocess.run(['git', '-C', str(repository), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + def commit(message, text): + payload.write_text(text) + git('add', 'config.txt') + git('commit', '--quiet', '-m', message) + + commit('test: base', 'setting=base\n') + git('checkout', '--quiet', '-b', 'feature') + commit('test: feature', 'setting=feature\n') + git('checkout', '--quiet', 'main') + commit('test: main', 'setting=main\n') + subprocess.run(['git', '-C', str(repository), 'merge', '--no-ff', 'feature', + '-m', 'test: conflicting feature'], capture_output=True, check=False) + # Resolving the conflict introduces a credential that exists only in the + # merge commit, not in either parent. + commit('test: merge resolution', 'setting=main\npassword=' + fake_value() + '\n') + with patch.object(SCANNER, 'ROOT', repository): + findings = SCANNER.scan(BINARY, 'git', repository, self.output) + self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings}) + + def test_fixture_directories_still_detect_unapproved_credentials(self): + for directory in ('tests',): + with self.subTest(directory=directory): + repository = self.root / directory.replace('/', '-') + repository.mkdir() + subprocess.run(['git', 'init', '--quiet', str(repository)], check=True) + payload = repository / directory / 'credentials.properties' + payload.parent.mkdir(parents=True) + payload.write_text('token=fake-' + fake_value() + '\n') + shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml') + with patch.object(SCANNER, 'ROOT', repository): + subprocess.run(['git', '-C', str(repository), 'add', '.'], check=True) + subprocess.run(['git', '-C', str(repository), '-c', 'user.name=Synthetic Test', + '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', + '-m', 'test: approved synthetic fixture'], check=True) + self.assertEqual(SCANNER.scan(BINARY, 'git', repository, self.output), []) + # Keep the default rule's entropy and word filters deterministic. + api_value = ''.join(character + str(index % 10) for index, character in enumerate( + secrets.SystemRandom().sample(string.ascii_letters, 16))) + payload.write_text('token=fake-' + fake_value() + '\n' + 'token=${X}' + fake_value() + '\n' + 'mongodb://fixture:' + fake_value() + '@example.invalid/db\n' + 'ALIBABA_ACCESS_KEY_SECRET=' + fake_value(30) + '\n' + 'api_key=' + api_value + '\n') + subprocess.run(['git', '-C', str(repository), 'add', '.'], check=True) + subprocess.run(['git', '-C', str(repository), '-c', 'user.name=Synthetic Test', + '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', + '-m', 'test: unapproved synthetic credentials'], check=True) + findings = SCANNER.scan(BINARY, 'git', repository, self.output) + expected = {'configuration-secret-value', 'mongodb-userinfo-password', + 'alibaba-access-key-secret', 'generic-api-key'} + self.assertTrue(expected <= {row['RuleID'] for row in findings}, + {row['RuleID'] for row in findings}) + # Directory scanning traverses relative paths before checking values. + result = subprocess.run( + [BINARY, 'dir', '.', '--config', str(repository / '.gitleaks.toml'), + '--redact', '--no-banner', '--report-format', 'json', + '--report-path', str(self.output)], + cwd=repository, capture_output=True, check=False) + self.assertEqual(result.returncode, 1) + findings = json.loads(self.output.read_text()) + self.assertTrue(expected <= {row['RuleID'] for row in findings}, + {row['RuleID'] for row in findings}) + + def test_runtime_uri_expression_exception_does_not_skip_source_file(self): + filename = '.github/scripts/test_secret_scan.py' + (self.source / filename).parent.mkdir(parents=True) + # This source expression is evaluated only when the fixture is generated. + expression = 'mongodb://fixture:' + '{fake_value()}' + '@example.invalid/db' + self.assertEqual(self.detect(expression, filename), []) + findings = self.detect(expression + '\n' + 'mongodb://fixture:' + fake_value() + + '@example.invalid/db', filename) + self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings}) + # The same literal is not exempted outside the scanner regression source. + findings = self.detect(expression, 'other.py') + self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings}) + + def test_clean_directory_passes(self): + self.assertEqual(SCANNER.scan(BINARY, 'dir', self.source, self.output), []) + + def test_fake_value_outside_test_paths_is_not_allowlisted(self): + findings = self.detect('token=fake-' + fake_value()) + self.assertTrue(findings) + + def test_artifact_findings_block_upload(self): + value = fake_value() + self.detect('jwt.secret=' + value) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.properties')), 1) + self.assertIn('RuleID', output.getvalue()) + self.assertNotIn(value, output.getvalue()) + + def test_empty_artifact_glob_passes_without_scanning_workspace(self): + self.detect('jwt.secret=' + fake_value()) + self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.absent')), 0) + + def test_missing_binary_fails(self): + with self.assertRaises(OSError): + SCANNER.scan(str(self.root / 'missing'), 'dir', self.source, self.output) + + def test_unredacted_baseline_is_rejected(self): + self.output.write_text(json.dumps([{'Secret': fake_value()}])) + with self.assertRaises(ValueError): + SCANNER.load_baseline(self.output) + + def test_history_baseline_passes_and_new_remote_commit_fails(self): + repository = self.root / 'repository' + subprocess.run(['git', 'init', '--quiet', '-b', 'security/leak-prevention-20261101', str(repository)], check=True) + + def git(*arguments): + return subprocess.run(['git', '-C', str(repository), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + git('config', 'user.name', 'Synthetic Test') + git('config', 'user.email', 'fixture@example.invalid') + shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml') + payload = repository / 'credentials.properties' + payload.write_text('jwt.secret=' + fake_value()) + git('add', '.') + git('commit', '--quiet', '-m', 'test: synthetic historical fixture') + old_commit = git('rev-parse', 'HEAD') + baseline = repository / '.gitleaks-baseline.json' + summary = self.root / 'summary.json' + with patch.object(SCANNER, 'ROOT', repository): + self.assertEqual(SCANNER.history(BINARY, baseline, summary, create=True), 0) + self.assertTrue(SCANNER.load_baseline(baseline)) + self.assertTrue(SCANNER.scan(BINARY, 'git', repository, self.output, baseline)) + self.assertEqual(SCANNER.history(BINARY, baseline, summary), 0) + payload.write_text('jwt.secret=' + fake_value()) + git('add', 'credentials.properties') + git('commit', '--quiet', '-m', 'test: new synthetic credential') + git('update-ref', 'refs/remotes/origin/security/leak-prevention-20261101', 'HEAD') + git('checkout', '--quiet', '--detach', old_commit) + self.assertEqual(SCANNER.history(BINARY, baseline, summary), 1) + rows = json.loads(summary.read_text()) + self.assertEqual(rows['new_findings'], 1) + self.assertEqual(set(rows), {'new_findings', 'by_rule'}) + self.assertTrue(all(isinstance(count, int) for count in rows['by_rule'].values())) + + +if __name__ == '__main__': + unittest.main() diff --git a/pr-governance/templates/codex-nudge.yml b/pr-governance/templates/codex-nudge.yml new file mode 100644 index 0000000..352362a --- /dev/null +++ b/pr-governance/templates/codex-nudge.yml @@ -0,0 +1,30 @@ +name: Review request + +# Copy to .github/workflows/codex-nudge.yml and replace __GOVERNANCE_REPO__. +# Asks the reviewer for one review per head that has no receipt yet. It does +# not judge the review; the risk gate remains the only pass/fail authority. + +on: + schedule: + - cron: '*/30 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + issues: write + +concurrency: + group: review-request + cancel-in-progress: false + +jobs: + nudge: + name: Request a review + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Request + uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__ + with: + mode: nudge diff --git a/pr-governance/templates/gitleaks.yml b/pr-governance/templates/gitleaks.yml new file mode 100644 index 0000000..8e1706b --- /dev/null +++ b/pr-governance/templates/gitleaks.yml @@ -0,0 +1,29 @@ +name: Secret scan + +# Copy to .github/workflows/gitleaks.yml and replace __GOVERNANCE_REPO__. +# The job name is the required status check context; keep it as `Gitleaks`. +# The scanner, its config and its pinned binary come from the governance +# repository, so a pull request cannot change the rules that judge it. + +on: + pull_request: + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Scan added commits + uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__ + with: + mode: scan diff --git a/pr-governance/templates/pr-review-signal.yml b/pr-governance/templates/pr-review-signal.yml new file mode 100644 index 0000000..0d9fc3b --- /dev/null +++ b/pr-governance/templates/pr-review-signal.yml @@ -0,0 +1,25 @@ +name: PR review signal + +# Copy to .github/workflows/pr-review-signal.yml and replace nothing: this +# workflow holds no permissions and runs no repository code. It exists because a +# review event on a fork or Dependabot pull request gets a read-only token, so +# the gate that publishes statuses has to be started by `workflow_run` instead. +# Keep the workflow name; risk-gate.yml refers to it. + +on: + pull_request_review: + types: [submitted, edited, dismissed] + pull_request_review_comment: + types: [created, edited, deleted] + +permissions: + contents: read + +jobs: + signal: + name: Signal review change + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Signal + run: echo "review event recorded" diff --git a/pr-governance/templates/risk-gate.yml b/pr-governance/templates/risk-gate.yml new file mode 100644 index 0000000..d12bbc9 --- /dev/null +++ b/pr-governance/templates/risk-gate.yml @@ -0,0 +1,91 @@ +name: PR risk gate + +# Copy to .github/workflows/risk-gate.yml and replace __GOVERNANCE_REPO__ with +# the organization's governance repository (for example Pigbibi/governance). +# The job names in this file are the required status check contexts; keep them. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review, closed] + issue_comment: + types: [created, edited, deleted] + # A review or inline comment on a fork or Dependabot pull request gets a + # read-only token, so those events are relayed through the permissionless + # `PR review signal` workflow and evaluated by this trusted run. + workflow_run: + workflows: ["PR review signal"] + types: [completed] + push: + branches: [main, master] + # A merge made with GITHUB_TOKEN raises no push, and nothing raises an event + # for a resolved review thread, so a sweep closes both gaps. + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + pr_number: + description: Existing pull request number to evaluate + required: true + type: string + +# The gate reads metadata only. It never checks out or runs pull request code. +permissions: + contents: read + pull-requests: read + issues: read + statuses: write + actions: write + +jobs: + # The labeled payload is the only authenticated record of which head a + # clearing label was added to. Recording it in its own job keeps the evidence + # independent of an evaluation that a later event may supersede. + record_label: + name: Record the head a clearing label was added to + if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: read + pull-requests: read + statuses: write + steps: + - name: Record label head + uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__ + env: + GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} + GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }} + with: + mode: gate + + evaluate: + name: Evaluate PR governance + needs: record_label + # A skipped recorder is the normal case for every other event. + if: >- + always() && needs.record_label.result != 'cancelled' && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + (github.event.comment.user.type == 'Bot' || + contains(github.event.comment.body || '', 'codex-review')))) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + issues: read + statuses: write + actions: write + # Job-level, so an irrelevant comment skips the job without cancelling an + # evaluation already running. Label evidence no longer rides on this run. + concurrency: + group: pr-governance-${{ github.event.pull_request.number || github.event.issue.number || github.event.workflow_run.pull_requests[0].number || inputs.pr_number || github.run_id }} + cancel-in-progress: true + steps: + - name: Evaluate + uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__ + env: + GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} + GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }} + with: + mode: gate diff --git a/pr-governance/templates/weekly-secret-scan.yml b/pr-governance/templates/weekly-secret-scan.yml new file mode 100644 index 0000000..339de27 --- /dev/null +++ b/pr-governance/templates/weekly-secret-scan.yml @@ -0,0 +1,38 @@ +name: Weekly secret scan + +# Copy to .github/workflows/weekly-secret-scan.yml and replace +# __GOVERNANCE_REPO__. Requires the SECRET_SCAN_BASELINE_B64 secret: the +# redacted identities of findings that predate the policy. The scan refuses to +# run without it, so an empty baseline can never be mistaken for a clean history. + +on: + schedule: + - cron: '23 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: weekly-secret-scan + cancel-in-progress: false + +jobs: + history: + name: Full-history secret scan + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Fetch all remote branches + run: git fetch origin '+refs/heads/*:refs/remotes/origin/*' + - name: Scan history + uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__ + env: + SECRET_SCAN_BASELINE_B64: ${{ secrets.SECRET_SCAN_BASELINE_B64 }} + with: + mode: scan + full-history: 'true'