diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b88718ae..42e8153c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,9 @@ jobs: workflow-consistency: name: Workflow consistency runs-on: ubuntu-latest - timeout-minutes: 10 + # Allow the full suite and setup to finish on slower runners while retaining + # a bounded job timeout. + timeout-minutes: 20 permissions: contents: read steps: @@ -138,6 +140,7 @@ jobs: - run: python3 scripts/retain-macos-test-binaries.test.py - run: python3 scripts/bank_statement_import.test.py - run: python3 scripts/sanitise-bbox-capture.test.py + - run: python3 scripts/merge-gate.test.py tally-portable: name: Tally portable core diff --git a/docs/proposed-merge-gate-ci.md b/docs/proposed-merge-gate-ci.md new file mode 100644 index 000000000..405a12457 --- /dev/null +++ b/docs/proposed-merge-gate-ci.md @@ -0,0 +1,157 @@ +# Proposed CI wiring for the shrunk merge gate + +`scripts/merge-gate.sh` was cut from a 3977-line manual CLI tool that gated +nothing automatically down to three checks that can run as real, required CI +status contexts: compatibility-surface validation, the privacy/PII scan, and +a minimal "a review exists naming the current head SHA" check. + +This document is a **proposal only**. Per the task boundary, nothing under +`.github/` was modified. A maintainer who agrees with this design should add +a new workflow file at `.github/workflows/merge-gate.yml` with the content +below (or fold it into `ci.yml`; see "Why a separate workflow file" below), +then add the job's context to branch protection's required status checks for +`master`. + +## Why a separate workflow file, not a job inside `ci.yml` + +`ci.yml` triggers only on `push`/`pull_request` (`opened, reopened, +synchronize, ready_for_review`) plus `workflow_dispatch`. That is enough for +the compatibility-surface and privacy checks, which only need the current +diff and are naturally re-evaluated on every push. It is **not** enough for +the review-evidence check: per issue #317, the entire point is to catch a PR +merged before any review artifact exists. A required check that can only +fire on `push`/`synchronize` would stay red forever once a reviewer finally +does leave a review or a completion summary comment, because neither of +those events re-triggers `ci.yml` — there would be no way to turn the check +green without an empty commit. + +So the merge-gate job needs to also listen for `pull_request_review` and +`issue_comment` (the two ways review evidence can appear: a real review +object, or — per the "clean review, no review object, only a reaction plus a +summary comment" case the check is written to handle — a completion comment +on the PR). Adding those events to `ci.yml`'s single `on:` block would +re-trigger the entire native/bundle/frontend build matrix on every PR +comment, which is wasteful and unrelated to what this gate checks. A +dedicated workflow file scopes the extra trigger events to only the cheap +job that needs them. + +## Proposed workflow + +```yaml +name: Merge gate + +on: + pull_request: + branches: [master] + types: [opened, reopened, synchronize, ready_for_review] + pull_request_review: + types: [submitted] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: PR number to re-check (defaults to the PR this run was triggered from) + required: false + binary_review_sha: + description: >- + Full 40-hex commit SHA a maintainer has manually reviewed for binary + byte/ownership/license/NOTICE content. Only needed when the PR adds + or changes a binary (or Git-LFS-pointer) file; leave blank otherwise. + required: false + independent_review_sha: + description: Full 40-hex commit SHA of the matching independent review attestation. + required: false + +concurrency: + group: merge-gate-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + cancel-in-progress: true + +jobs: + merge-gate: + name: Merge gate + runs-on: ubuntu-latest + timeout-minutes: 10 + # issue_comment fires for both issues and PRs; only run for PR comments. + if: ${{ github.event_name != 'issue_comment' || github.event.issue.pull_request != null }} + permissions: + contents: read + pull-requests: read + issues: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Resolve PR number + id: pr + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }} + run: | + set -euo pipefail + number="${PR_NUMBER:-${ISSUE_NUMBER:-${DISPATCH_PR_NUMBER:-}}}" + if [ -z "$number" ]; then + echo "could not resolve a PR number for this event" >&2 + exit 2 + fi + echo "number=$number" >> "$GITHUB_OUTPUT" + - name: Run merge gate + env: + GH_TOKEN: ${{ github.token }} + BINARY_REVIEW_SHA: ${{ github.event.inputs.binary_review_sha }} + INDEPENDENT_REVIEW_SHA: ${{ github.event.inputs.independent_review_sha }} + run: | + set -euo pipefail + args=(scripts/merge-gate.sh "${{ steps.pr.outputs.number }}" --repo "${{ github.repository }}") + [ -n "$BINARY_REVIEW_SHA" ] && args+=(--binary-review-sha "$BINARY_REVIEW_SHA") + [ -n "$INDEPENDENT_REVIEW_SHA" ] && args+=(--independent-review-sha "$INDEPENDENT_REVIEW_SHA") + "${args[@]}" +``` + +Notes on this draft: + +- Exit codes line up with what a required check needs: `merge-gate.sh` exits + `0` (pass), `1` (block), or `2` (indeterminate) — both `1` and `2` are + non-zero, so the job step fails and the required check goes red for either + outcome. There is no separate "neutral" state; INDETERMINATE is treated as + blocking, matching the script's own stance that unknown evidence is never + converted into an empty successful set. +- `pull-requests: read` and `issues: read` are enough: every `gh api`/`gh pr` + call the shrunk script makes is a read against the same repository, so the + default `GITHUB_TOKEN` (`github.token`) needs no elevated scope. +- The `--binary-review-sha`/`--independent-review-sha` attestations are + inherently a human judgment call (someone read the actual binary bytes, + ownership, license, and NOTICE obligations) and cannot be automated. The + `workflow_dispatch` inputs above are the proposed escape hatch: a + maintainer who has done that review re-runs this workflow by hand with + both SHAs filled in. Until that happens, any PR that adds or changes a + binary (including a Git LFS pointer, per the #360 fix) correctly stays + blocked on the automatic `pull_request`/`pull_request_review`/ + `issue_comment` triggers. +- Add the job's status context — **"Merge gate"** — to `master`'s branch + protection required status checks alongside the existing "Frontend build", + "Rust format", "GitGuardian Security Checks", "Dependency security", and + "Required checks" contexts. It intentionally is **not** folded into the + existing `required-checks` rollup job in `ci.yml`, both because it lives in + a different workflow (a rollup job can only depend on jobs in its own + workflow run) and because keeping it as its own context makes it possible + to see at a glance, on the PR, exactly which of the three concerns + (surface reseal, privacy scan, review evidence) failed — `merge-gate.sh` + prints a `BLOCK`/`INDETERMINATE`/`ok` line per check in its log output. +- `concurrency` is keyed by PR number (falling back across the three event + shapes) so a review submitted while a push-triggered run is still in + flight doesn't race it; the newer run's result is what branch protection + sees. + +## What this intentionally leaves for GitHub itself + +`scripts/merge-gate.sh` no longer re-derives PR/head/base identity binding, +branch-protection required-check contexts, or a checks rollup — that logic +(roughly 900 of the cut lines) duplicated what `master`'s branch protection +already enforces natively once "Merge gate" is added as a required context: +GitHub itself refuses to merge a PR that is behind, has an unresolved +required context, or whose head has moved since a context last reported +success. Required status checks, not this script, are the source of truth +for "is everything else green." diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 19b1d469c..0b402bc5b 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e9383d71ef425790729a2355476ea381f04f3f6e587ff43042bd3e60eeb422aa", + "compatibility_surface_sha256": "a2e72e983a53ed8a2d99b9a4b8dda2d74794e3dd44d89987076bb32299421246", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f76b98ea5..c936803c2 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -3,7 +3,7 @@ "files": [ { "path": ".github/workflows/ci.yml", - "sha256": "2d2220d0dc1942eab04034dbfd07f5ecf693cc7d5f47b1e31615fe17e4b2f140" + "sha256": "aeed5fab97de591ed8ad1cf8cf094eaaac5356d40adbd10158905ec19316fd2f" }, { "path": ".github/workflows/dependency-security.yml", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e9383d71ef425790729a2355476ea381f04f3f6e587ff43042bd3e60eeb422aa" + "manifest_sha256": "a2e72e983a53ed8a2d99b9a4b8dda2d74794e3dd44d89987076bb32299421246" } \ No newline at end of file diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh new file mode 100755 index 000000000..e6cbc99a8 --- /dev/null +++ b/scripts/merge-gate.sh @@ -0,0 +1,552 @@ +#!/usr/bin/env bash +# Validate a pull request's compatibility-surface reseal, run the privacy/PII +# scan, and confirm review evidence names the current head SHA. +# +# Usage: scripts/merge-gate.sh [--repo OWNER/NAME] +# [--independent-review-sha FULL_SHA] (manual attestation naming the head) +# [--binary-review-sha FULL_SHA] (manual binary-byte, ownership, license, and NOTICE review) +# Exit: 0 may merge, 1 must not, 2 could not determine. +# +# This script intentionally does not re-derive PR/head/base identity binding, +# branch-protection required-check contexts, or a checks rollup: those are +# GitHub's own job, enforced natively by required status checks on master +# (see docs/proposed-merge-gate-ci.md). Every check here is bound to one +# server-observed head SHA; unknown or incomplete evidence is never +# converted into an empty successful set. + +set -uo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || { + echo "could not resolve merge-gate script directory" >&2 + exit 2 +} + +PR="" +REPO="" +INDEPENDENT_REVIEW_SHA="" +BINARY_REVIEW_SHA="" +while [ $# -gt 0 ]; do + case "$1" in + --repo) + REPO="${2:-}" + [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; } + shift 2 + ;; + --repo=*) + REPO="${1#--repo=}" + [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; } + shift + ;; + --independent-review-sha) + INDEPENDENT_REVIEW_SHA="${2:-}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha needs a full commit SHA" >&2; exit 2; } + shift 2 + ;; + --independent-review-sha=*) + INDEPENDENT_REVIEW_SHA="${1#*=}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha= needs a full commit SHA" >&2; exit 2; } + shift + ;; + --binary-review-sha) + BINARY_REVIEW_SHA="${2:-}" + [ -n "$BINARY_REVIEW_SHA" ] || { echo "--binary-review-sha needs a full commit SHA" >&2; exit 2; } + shift 2 + ;; + --binary-review-sha=*) + BINARY_REVIEW_SHA="${1#*=}" + [ -n "$BINARY_REVIEW_SHA" ] || { echo "--binary-review-sha= needs a full commit SHA" >&2; exit 2; } + shift + ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 + ;; + -*) + echo "unknown option: $1" >&2 + exit 2 + ;; + *) + if [ -z "$PR" ]; then + PR="$1" + else + echo "unexpected argument: $1" >&2 + exit 2 + fi + shift + ;; + esac +done +[ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } +[[ "$PR" =~ ^[0-9]+$ ]] || { echo "PR selector must be numeric" >&2; exit 2; } + +# Resolve the repository once. An explicit target must never fall back to the +# current checkout if one later API call fails. +if [ -z "$REPO" ]; then + if ! REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then + echo "could not determine repository; pass --repo OWNER/NAME" >&2 + exit 2 + fi +fi +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +if [ -z "$OWNER" ] || [ -z "$NAME" ] || [ "$OWNER" = "$REPO" ] || [[ "$REPO" == */*/* ]] \ + || ! [[ "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ + || ! [[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "--repo must be OWNER/NAME, got '$REPO'" >&2 + exit 2 +fi + +# This command encodes Bridge-specific master workflow and surface policy. +# An arbitrary repository's passing checks cannot qualify that contract. +if [ "$REPO" != "lamemustafa/bridge" ]; then + echo "unsupported repository: this gate implements lamemustafa/bridge policy" >&2 + exit 2 +fi + +fail=0 +uncertain=0 +tmpdir=$(mktemp -d) +errfile="$tmpdir/error" +trap 'rm -rf "$tmpdir"' EXIT +say() { printf ' %-13s %s\n' "$1" "$2"; } +bad() { say "BLOCK" "$1"; fail=1; } +unknown() { say "INDETERMINATE" "$1"; uncertain=1; } +die() { echo "$1" >&2; exit 2; } +# A malformed response is different from a valid empty result. Validate the +# outer shape before extracting fields so jq errors cannot become empty values. +: >"$errfile" +if ! meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefName,title,body,changedFiles 2>"$errfile"); then + die "could not read PR #$PR in $REPO" +fi +if ! jq -e ' + type == "object" and + (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.baseRefName | type == "string" and length > 0) and + (.title | type == "string") and + (.changedFiles | type == "number" and floor == . and . >= 0) +' <<<"$meta" >/dev/null 2>&1; then + die "PR metadata was not a valid complete JSON object" +fi +head=$(jq -r '.headRefOid' <<<"$meta") +base=$(jq -r '.baseRefName' <<<"$meta") +changed_files_expected=$(jq -r '.changedFiles' <<<"$meta") +short=${head:0:7} +title=$(jq -r '.title' <<<"$meta") +raw_prbody=$(jq -r '.body // ""' <<<"$meta") +prbody="$raw_prbody" +visible_body_status=0 +prbody=$(python3 -c 'import re, sys +text = sys.stdin.read() +print(re.sub(r"|\Z)", "", text, flags=re.S), end="")' <<<"$prbody") || visible_body_status=$? +if [ "$visible_body_status" -ne 0 ]; then + unknown "could not extract visible PR description content" + prbody="" +fi + +if [ -n "$INDEPENDENT_REVIEW_SHA" ] && ! [[ "$INDEPENDENT_REVIEW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + bad "independent review attestation must be a full 40-hex commit SHA" +elif [ -n "$INDEPENDENT_REVIEW_SHA" ] && [ "$INDEPENDENT_REVIEW_SHA" != "$head" ]; then + bad "independent review attestation names a different commit than the PR head" +fi +if [ -n "$BINARY_REVIEW_SHA" ] && ! [[ "$BINARY_REVIEW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + bad "binary review attestation must be a full 40-hex commit SHA" +elif [ -n "$BINARY_REVIEW_SHA" ] && [ "$BINARY_REVIEW_SHA" != "$head" ]; then + bad "binary review attestation names a different commit than the PR head" +fi +echo "PR #$PR ($REPO) head=$short base=$base" + +# Capture the base tip independently. A PR can retain the same base name while +# the branch advances during this run. +: >"$errfile" +base_tip_status=0 +base_tip=$(gh api "repos/$REPO/branches/$base" --jq '.commit.sha' 2>"$errfile") || base_tip_status=$? +if [ "$base_tip_status" -ne 0 ] || ! [[ "$base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + unknown "could not read the full current tip of base '$base'" + base_tip="" +else + say "ok" "captured base tip ${base_tip:0:7}" +fi +# Scan all published PR metadata. Commit messages are paginated because they +# can become squash subjects or release evidence independently of the patch. +: >"$errfile" +metadata_pr_status=0 +metadata_pr=$(gh api "repos/$REPO/pulls/$PR" 2>"$errfile") || metadata_pr_status=$? +if [ "$metadata_pr_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "object" and + (.commits | type == "number" and floor == . and . > 0 and . <= 250) and + (.head | type == "object" and + (.sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head)) +' <<<"$metadata_pr" >/dev/null 2>&1; then + unknown "could not prove complete head-bound PR commit metadata for the privacy scan" + privacy_metadata="" +else + metadata_commit_total=$(jq -r '.commits' <<<"$metadata_pr") +fi + +: >"$errfile" +metadata_status=0 +metadata_commits=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/commits?per_page=100" 2>"$errfile") || metadata_status=$? +if [ -z "${metadata_commit_total:-}" ] || [ "$metadata_status" -ne 0 ] || ! jq -e --argjson expected "$metadata_commit_total" --arg head "$head" ' + type == "array" and (all(.[]; type == "array") or all(.[]; type == "object")) and + ((if all(.[]; type == "array") then flatten else . end) | + length == $expected and + ([.[].sha] | unique | length) == $expected and + any(.[]; .sha == $head) and + all(.[]; type == "object" and + (.sha | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.commit | type == "object") and + (.commit.message | type == "string") and + (.commit.author | type == "object" and + (.name | type == "string") and (.email | type == "string" and test("^[A-Za-z0-9.!#$%&*+/=?^_`{|}~-]+@[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$"))) and + (.commit.committer | type == "object" and + (.name | type == "string") and (.email | type == "string" and test("^[A-Za-z0-9.!#$%&*+/=?^_`{|}~-]+@[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$"))) and + ((.author == null) or (.author | type == "object" and (.login | type == "string"))) and + ((.committer == null) or (.committer | type == "object" and (.login | type == "string"))))) +' <<<"$metadata_commits" >/dev/null 2>&1; then + unknown "could not prove complete head-bound PR commit metadata for the privacy scan" + privacy_metadata="" +else + # Author and committer emails were structurally validated above and are an + # explicit identity-only source class. Do not mix them into payload/path/ + # metadata scan input, where an identical address would be customer data. + commit_messages=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | + [.commit.message, .commit.author.name, .commit.committer.name, + (.author.login? // null), (.committer.login? // null)] | + map(select(. != null))[]' <<<"$metadata_commits") + privacy_metadata="$title +$raw_prbody +$commit_messages" +fi +# Paginate changed files through the REST endpoint; gh pr view hard-codes a +# first:100 GraphQL fragment in some versions. Retain the line counts as well: +# the privacy scan can only be complete when the textual diff describes every +# non-removed destination with the byte count GitHub reported. +: >"$errfile" +files_status=0 +files=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/files?per_page=100" 2>"$errfile") || files_status=$? +changed_records="$tmpdir/changed-files.tsv" +if [ "$files_status" -ne 0 ] || ! jq -e ' + type == "array" and + (all(.[]; type == "array" and all(.[]; + type == "object" and + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\u0000-\u001F\u007F]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + (((.previous_filename? == null) or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not))) and ((.status != "renamed") or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not)))) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) or + all(.[]; type == "object" and + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\u0000-\u001F\u007F]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + (((.previous_filename? == null) or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not))) and ((.status != "renamed") or (((.previous_filename | type) == "string") and ((.previous_filename | length) > 0) and ((.previous_filename | test("[\u0000-\u001F\u007F]")) | not)))) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) +' <<<"$files" >/dev/null 2>&1; then + unknown "could not read the complete changed-file set" + changed="" +else + jq -r '(if all(.[]; type == "array") then flatten else . end)[] | [.filename, .status, .additions, .deletions, (.previous_filename? // "")] | @tsv' <<<"$files" >"$changed_records" + changed=$(cut -f1 "$changed_records") + changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | length' <<<"$files") + unique_changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | map(.filename) | unique | length' <<<"$files") + if [ "$changed_count" -eq 0 ]; then + unknown "changed-file response contained no filenames" + elif [ "$changed_files_expected" -gt 3000 ]; then + unknown "PR reports $changed_files_expected changed files beyond the REST files API cap" + elif [ "$changed_count" -ne "$changed_files_expected" ] || [ "$unique_changed_count" -ne "$changed_count" ]; then + unknown "changed-file response has $changed_count unique records; PR metadata reports $changed_files_expected" + fi +fi +# Read and validate the v1 surface as a required object. Any transport, +# decoding, JSON, or schema failure is indeterminate; an unrelated nested +# `path` must not turn an incomplete manifest into an empty pin set. Both the +# reviewed head and captured base tip are checked: a head surface that silently +# drops a previously pinned path is a human hold, and changed paths are tested +# against the union so an unpinned head cannot hide a reseal obligation. +SURFACE="docs/tally/compatibility/compatibility-surface.json" +read_surface_paths() { + local ref="$1" + local response content decoded decode_status + surface_paths_result="" + : >"$errfile" + response=$(gh api "repos/$REPO/contents/$SURFACE?ref=$ref" 2>"$errfile") || return 1 + content=$(jq -er 'select(.encoding == "base64") | .content | strings' <<<"$response") || return 1 + decoded="" + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? + if [ "$decode_status" -ne 0 ]; then + decode_status=0 + decoded=$(printf '%s' "${content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? + fi + if [ "$decode_status" -ne 0 ] || ! jq -e ' + type == "object" and + .schema_version == 1 and + ((.manifest_sha256 | type) == "string") and (.manifest_sha256 | test("^[0-9a-f]{64}$")) and + (.files | type == "array" and length > 0 and + all(.[]; type == "object" and + ((.path | type) == "string") and (.path | length > 0) and + ((.sha256 | type) == "string") and (.sha256 | test("^[0-9a-f]{64}$")))) and + (([.files[].path] | length) == ([.files[].path] | unique | length)) + ' <<<"$decoded" >/dev/null 2>&1; then + return 1 + fi + surface_paths_result=$(jq -r '.files[].path' <<<"$decoded") +} + +head_surface_status=0 +read_surface_paths "$head" || head_surface_status=$? +if [ "$head_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at $short" + pinned="" +else + pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at head $short" +fi + +base_surface_status=0 +if [ -n "$base_tip" ]; then + read_surface_paths "$base_tip" || base_surface_status=$? +fi +if [ -n "$base_tip" ] && [ "$base_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at base ${base_tip:0:7}" + base_pinned="" +elif [ -n "$base_tip" ]; then + base_pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at base ${base_tip:0:7}" +else + base_pinned="" +fi + +if [ -n "$changed" ] && [ -n "$pinned" ] && [ -n "$base_pinned" ]; then + removed_pins=$(comm -23 <(sort -u <<<"$base_pinned") <(sort -u <<<"$pinned")) + if [ -n "$removed_pins" ]; then + removed_count=$(wc -l <<<"$removed_pins" | tr -d ' ') + unknown "$removed_count base-pinned path(s) are absent from the head surface; human review is required" + fi + union_pinned=$(printf '%s\n%s\n' "$base_pinned" "$pinned" | sort -u) + touched=$(comm -12 <(sort -u <<<"$union_pinned") <(sort -u <<<"$changed") | awk -v surface="$SURFACE" '$0 != surface') + if [ -z "$touched" ]; then + say "ok" "changed files contain no pinned path requiring a reseal" + elif grep -Fxq "$SURFACE" <<<"$changed"; then + say "ok" "changed pinned paths include the compatibility surface" + else + bad "changed pinned paths omit the compatibility surface reseal" + fi +fi +# Scan destination paths and added payload lines. Removed/context lines never +# enter the privacy scan. Binary additions are an explicit human-inspection +# hold because their bytes are absent from a textual patch. +: >"$errfile" +diff_status=0 +diff=$(gh pr diff "$PR" --repo "$REPO" 2>"$errfile") || diff_status=$? +if [ "$diff_status" -ne 0 ] || [ -z "$diff" ]; then + unknown "could not read diff for the privacy scan" +else + # Parse Git's C-style quoted paths with the existing Python runtime. The + # shell receives only JSON/TSV data after the parser has matched every diff + # section, so non-ASCII destinations retain their REST filename identity. + diff_stats="$tmpdir/diff-stats.tsv" + added_payload="$tmpdir/added-payload" + parsed_diff_status=0 + parsed_diff=$(python3 scripts/merge_gate_diff.py <<<"$diff") || parsed_diff_status=$? + if [ "$parsed_diff_status" -ne 0 ] || ! jq -e ' + type == "object" and + (.records | type == "array" and all(.[]; type == "object" and + (.destination | type == "string" and length > 0) and + ((.textual_destination == null) or (.textual_destination | type == "string" and length > 0)) and + (.added | type == "number" and floor == . and . >= 0) and + (.deleted | type == "number" and floor == . and . >= 0) and + (.binary | type == "boolean") and (.gitlink | type == "boolean"))) and + (.added_payload | type == "array" and all(.[]; type == "string")) + ' <<<"$parsed_diff" >/dev/null 2>&1; then + unknown "could not parse diff sections for the privacy scan" + : >"$diff_stats" + : >"$added_payload" + else + jq -r '.records[] | [.destination, .added, .deleted, (if .textual_destination == null then 0 else 1 end), (if .binary then 1 else 0 end), (if .gitlink then 1 else 0 end)] | @tsv' <<<"$parsed_diff" >"$diff_stats" + jq -r '.added_payload[]' <<<"$parsed_diff" >"$added_payload" + + coverage_count=0 + coverage_examples="" + metadata_only_count=0 + metadata_only_examples="" + binary_count=0 + gitlink_count=0 + bounded_coverage_name() { + local item + item=$(sed -E 's#/(Users|home)/[^/[:space:]]+##g; s#[A-Za-z]:[\\/]+Users[\\/]+[^\\/[:space:]]+##g' <<<"$1") + # The privacy classifier's own identifier-shape detection also runs on + # the (already home-dir-redacted) filename: a coverage/metadata-only + # example is echoed verbatim into gate output, and a filename can itself + # carry an identifier, digest, or email shape. + item=$(printf '%s' "$item" | python3 "$script_dir/merge_gate_privacy.py" --redact-shapes 2>/dev/null) || item="$item" + printf '%s' "${item:0:160}" + } + record_coverage_issue() { + coverage_count=$((coverage_count + 1)) + if [ "$coverage_count" -le 8 ]; then + local item + item=$(bounded_coverage_name "$1") + coverage_examples="${coverage_examples}${coverage_examples:+; }$item" + fi + } + record_metadata_only() { + metadata_only_count=$((metadata_only_count + 1)) + if [ "$metadata_only_count" -le 8 ]; then + metadata_only_examples="${metadata_only_examples}${metadata_only_examples:+; }$(bounded_coverage_name "$1")" + fi + } + path_text="" + if [ -s "$changed_records" ]; then + while IFS=$'\t' read -r filename status rest_added rest_deleted previous_filename; do + match_count=$(awk -F '\t' -v filename="$filename" '$1 == filename { count++ } END { print count+0 }' "$diff_stats") + if [ "$match_count" -ne 1 ]; then + record_coverage_issue "omits or duplicates '$filename'" + continue + fi + diff_record=$(awk -F '\t' -v filename="$filename" '$1 == filename { print; exit }' "$diff_stats") + IFS=$'\t' read -r _ diff_added diff_deleted textual binary gitlink <<<"$diff_record" + if [ "$gitlink" -eq 1 ]; then + gitlink_count=$((gitlink_count + 1)) + fi + if [ "$binary" -eq 1 ]; then + # Its bytes cannot be reconciled through textual hunks. + binary_count=$((binary_count + 1)) + continue + fi + if [ "$status" = "removed" ]; then + # A removed record has no new content, so it is excluded from the + # metadata-only/textual-destination checks below (its +++ destination + # is legitimately /dev/null). It must still be reconciled first: a + # removed record absent from the diff was already caught by the + # match_count check above, and its line totals must still agree with + # REST before it is excluded from further scanning. + if [ "$diff_added" -ne "$rest_added" ] || [ "$diff_deleted" -ne "$rest_deleted" ]; then + record_coverage_issue "line totals for '$filename' differ from REST metadata" + fi + continue + fi + if [ "$textual" -ne 1 ] && [ "$diff_added" -eq 0 ] && [ "$diff_deleted" -eq 0 ]; then + if [ "$rest_added" -eq 0 ] && [ "$rest_deleted" -eq 0 ]; then + record_metadata_only "$filename" + else + record_coverage_issue "metadata-only '$filename' conflicts with REST line totals" + fi + elif [ "$textual" -ne 1 ]; then + record_coverage_issue "lacks a textual destination for '$filename'" + elif [ "$diff_added" -ne "$rest_added" ] || [ "$diff_deleted" -ne "$rest_deleted" ]; then + record_coverage_issue "line totals for '$filename' differ from REST metadata" + fi + done <"$changed_records" + # Reconciliation above is REST-to-diff only: it proves every REST record + # has exactly one matching diff section, but says nothing about a diff + # destination that has no REST record at all. Check the reverse direction + # too, so a diff section smuggled in outside the REST changed-file set + # cannot silently skip the privacy scan's REST-driven bookkeeping. + extra_diff_destinations=$(comm -13 <(cut -f1 "$changed_records" | sort -u) <(cut -f1 "$diff_stats" | sort -u)) + if [ -n "$extra_diff_destinations" ]; then + while IFS= read -r extra; do + record_coverage_issue "diff destination '$extra' has no corresponding REST record" + done <<<"$extra_diff_destinations" + fi + # This option is an operator statement, never evidence inferred from the + # PR body: it attests that every current binary addition/change byte and + # its ownership, license, and NOTICE obligations were independently read. + if [ "$binary_count" -gt 0 ]; then + if [ "$BINARY_REVIEW_SHA" = "$head" ] && [ "$INDEPENDENT_REVIEW_SHA" = "$head" ]; then + say "ok" "$binary_count binary addition/change(s) have explicit current-head binary and independent review attestations" + else + bad "$binary_count binary addition/change(s) require matching --binary-review-sha and --independent-review-sha human attestations" + fi + fi + [ "$gitlink_count" -eq 0 ] || unknown "$gitlink_count gitlink change(s) require explicit provenance, license, and NOTICE review" + if [ "$coverage_count" -gt 0 ]; then + unknown "privacy diff coverage failed for $coverage_count non-removed REST file(s): $coverage_examples" + fi + if [ "$metadata_only_count" -gt 0 ]; then + say "note" "$metadata_only_count metadata-only diff section(s) have REST 0/0 totals: $metadata_only_examples" + fi + # Destination paths are scan input from the complete REST set, not only + # from whatever textual patch GitHub happened to render. Removed paths + # carry no newly added material and are deliberately excluded. + path_text=$(awk -F '\t' '$2 != "removed" { print $1 }' "$changed_records") + fi + scan_input_file="$tmpdir/privacy-scan-input" + scan_input_write_status=0 + { + printf '%s\n%s\n' "$privacy_metadata" "$path_text" + cat "$added_payload" + } >"$scan_input_file" || scan_input_write_status=$? + if [ "$scan_input_write_status" -ne 0 ]; then + unknown "could not assemble complete privacy scan input" + else + privacy_result_status=0 + privacy_result=$(python3 "$script_dir/merge_gate_privacy.py" --head "$head" <"$scan_input_file") || privacy_result_status=$? + if [ "$privacy_result_status" -ne 0 ] || ! jq -e ' + type == "object" and + (.blockers | type == "array" and all(.[]; type == "string" and length > 0)) and + (.indeterminate | type == "array" and all(.[]; type == "string" and length > 0)) and + (.notes | type == "array" and all(.[]; type == "string" and length > 0)) + ' <<<"$privacy_result" >/dev/null 2>&1; then + unknown "privacy classifier failed or returned malformed output" + else + while IFS= read -r message; do bad "$message"; done < <(jq -r '.blockers[]' <<<"$privacy_result") + while IFS= read -r message; do unknown "$message"; done < <(jq -r '.indeterminate[]' <<<"$privacy_result") + while IFS= read -r message; do say "note" "$message"; done < <(jq -r '.notes[]' <<<"$privacy_result") + fi + fi + fi +fi +# A review naming the exact current head SHA. Closes #317: PRs were merged +# 2-8 minutes after opening, where "zero unresolved review threads" meant +# "the reviewer had not started", not "reviewed clean". A clean review can +# leave no review object at all -- only a reaction plus a summary comment -- +# so the absence of any review artifact naming this exact head is a FAIL, +# never a silent pass. +: >"$errfile" +review_evidence_status=0 +head_reviews=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/reviews" 2>"$errfile") || review_evidence_status=$? +review_names_head=false +if [ "$review_evidence_status" -eq 0 ] && jq -e ' + type == "array" and (all(.[]; type == "array") or all(.[]; type == "object")) +' <<<"$head_reviews" >/dev/null 2>&1; then + review_names_head=$(jq -r --arg head "$head" ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; .commit_id == $head) + ' <<<"$head_reviews") +fi +comment_names_head=false +if [ "$review_names_head" != "true" ]; then + : >"$errfile" + head_comments_status=0 + head_comments=$(gh api --paginate --slurp "repos/$REPO/issues/$PR/comments" 2>"$errfile") || head_comments_status=$? + if [ "$head_comments_status" -eq 0 ] && jq -e ' + type == "array" and (all(.[]; type == "array") or all(.[]; type == "object")) + ' <<<"$head_comments" >/dev/null 2>&1; then + short_marker="\`${short}\`" + comment_names_head=$(jq -r --arg head "$head" --arg marker "$short_marker" ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; ((.body // "") | contains($head)) or ((.body // "") | contains($marker))) + ' <<<"$head_comments") + fi +fi +if [ "$review_names_head" = "true" ] || [ "$comment_names_head" = "true" ]; then + say "ok" "review evidence names the current head $short" +else + bad "no review evidence (review or comment) names current head $short — see #317" +fi +echo +if [ "$fail" -ne 0 ]; then + echo "MUST NOT MERGE" + exit 1 +fi +if [ "$uncertain" -ne 0 ]; then + echo "INDETERMINATE — do not merge until the missing evidence is obtained" + exit 2 +fi + +echo "MAY MERGE — compatibility-surface, privacy, and head-SHA review-evidence checks passed for $short." +echo "Branch protection on $base enforces the remaining required status checks separately." +printf ' [ "$(gh pr view %s --repo %s --json baseRefName -q .baseRefName)" = "%s" ] \\\n && gh pr merge %s --repo %s --squash --match-head-commit %s\n' \ + "$PR" "$REPO" "$base" "$PR" "$REPO" "$head" +exit 0 diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py new file mode 100644 index 000000000..8ab1c1efb --- /dev/null +++ b/scripts/merge-gate.test.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Focused offline controls for the shrunk scripts/merge-gate.sh. + +The gate was cut from 3977 lines of merge-gate.sh/.test.py/_fake_gh.py/ +_privacy.py/_diff.py down to only what runs as a real required CI check: +compatibility-surface validation, the privacy/PII scan, and a minimal +review-evidence-names-current-head check. This suite exercises only that +kept surface, plus a dedicated regression test for each of the 7 defects +fixed in the kept code (#346, #358, #360, #365, #366, #373, #377). + +The fake `gh` (merge_gate_test_gh.py) models server responses; no network or +merge operation is used. +""" +from __future__ import annotations + +import importlib.util +import os +import shutil +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "merge-gate.sh" +FAKE_GH = ROOT / "scripts" / "merge_gate_test_gh.py" +PRIVACY_MODULE_PATH = ROOT / "scripts" / "merge_gate_privacy.py" +HEAD = "0123456789abcdef0123456789abcdef01234567" +SHORT = HEAD[:7] + + +def load_privacy_module(): + """Load scripts/merge_gate_privacy.py fresh, bypassing sys.modules. + + A fresh load (rather than a cached import) matters here: several of the + PrivacyScannerFindingsPR335 tests are run by hand against a deliberately + reverted copy of the file to prove they fail on the pre-fix behavior + described in PR #335 review, then re-run against the restored file. A + cached import would silently keep serving the first version loaded. + """ + spec = importlib.util.spec_from_file_location("merge_gate_privacy", PRIVACY_MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +MKTEMP_WRAPPER = """#!/usr/bin/env bash +# Only scan-input-write-failure pre-occupies the privacy-scan-input path (as +# a directory) so the write inside merge-gate.sh's redirect group fails. +real=$(command -v -p mktemp) +if [ "${GATE_SCENARIO:-}" = "scan-input-write-failure" ] && [ "$1" = "-d" ]; then + dir=$("$real" -d) + mkdir "$dir/privacy-scan-input" + printf '%s\\n' "$dir" +else + exec "$real" "$@" +fi +""" + + +class MergeGateControls(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory(prefix="merge-gate-controls-") + cls.bin = Path(cls.tmp.name) + gh = cls.bin / "gh" + shutil.copyfile(FAKE_GH, gh) + gh.chmod(0o755) + mktemp = cls.bin / "mktemp" + mktemp.write_text(MKTEMP_WRAPPER) + mktemp.chmod(mktemp.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + if not shutil.which("jq"): + raise RuntimeError("jq is required for merge-gate controls") + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_gate(self, scenario="pass", extra_args=()): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + env["GATE_SCENARIO"] = scenario + return subprocess.run( + [str(SCRIPT), "321", "--repo", "lamemustafa/bridge", *extra_args], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def assert_pass(self, scenario, phrase=None, extra_args=()): + result = self.run_gate(scenario, extra_args) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("MAY MERGE", result.stdout) + if phrase: + self.assertIn(phrase, result.stdout) + return result + + def assert_blocked(self, scenario, phrase, extra_args=()): + result = self.run_gate(scenario, extra_args) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + return result + + def assert_indeterminate(self, scenario, phrase, extra_args=()): + result = self.run_gate(scenario, extra_args) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + return result + + # -- Baseline ----------------------------------------------------------- + + def test_pass_scenario_may_merge(self): + self.assert_pass("pass", "review evidence names the current head") + + # -- Compatibility-surface validation (KEEP #1) -------------------------- + + def test_surface_fetch_failure_is_indeterminate(self): + self.assert_indeterminate("surface-fetch-fail", "could not read and validate compatibility surface at " + SHORT) + + def test_surface_malformed_is_indeterminate(self): + self.assert_indeterminate("surface-malformed", "could not read and validate compatibility surface at " + SHORT) + + def test_base_pinned_path_removed_from_head_is_indeterminate(self): + self.assert_indeterminate("surface-unpins", "base-pinned path(s) are absent from the head surface") + + def test_touched_pinned_path_without_reseal_blocks(self): + self.assert_blocked("surface-reseal-missing", "changed pinned paths omit the compatibility surface reseal") + + def test_touched_pinned_path_with_reseal_passes(self): + self.assert_pass("surface-reseal-present", "changed pinned paths include the compatibility surface") + + # -- Privacy / PII scan (KEEP #2) ---------------------------------------- + + def test_email_in_title_blocks(self): + self.assert_blocked("privacy-email-blocker", "customer email shape") + + def test_credential_literal_blocks(self): + self.assert_blocked("privacy-credential-blocker", "literal credential, bearer, or API token value") + + def test_unknown_uuid_is_indeterminate(self): + self.assert_indeterminate("privacy-uuid-indeterminate", "without exact current-head fixture provenance") + + def test_binary_addition_without_attestation_blocks(self): + self.assert_blocked("binary-missing-attestation", "require matching --binary-review-sha and --independent-review-sha") + + def test_binary_addition_with_attestation_passes(self): + self.assert_pass( + "binary-with-attestation", + "have explicit current-head binary and independent review attestations", + extra_args=("--binary-review-sha", HEAD, "--independent-review-sha", HEAD), + ) + + def test_gitlink_change_is_indeterminate(self): + self.assert_indeterminate("gitlink-change", "gitlink change(s) require explicit provenance") + + # -- Minimal head-SHA review-evidence check (KEEP #3, new) --------------- + + def test_review_object_naming_head_passes(self): + self.assert_pass("review-names-head-via-review", "review evidence names the current head") + + def test_summary_comment_naming_head_passes(self): + self.assert_pass("review-names-head-via-comment", "review evidence names the current head") + + def test_no_review_evidence_blocks(self): + self.assert_blocked("review-missing", "no review evidence (review or comment) names current head") + self.assertIn("#317", self.run_gate("review-missing").stdout) + + # -- Regression tests for the 7 fixes in the kept code ------------------- + + def test_346_parenthesized_date_range_not_flagged(self): + # Before the fix, GROUPED_NUMBER_RE's captured boundary character + # (here "(" and ")") was left in match.group(0), so DATE_RANGE_RE's + # fullmatch never matched and the date range was misreported as an + # unexplained long digit run. + result = self.assert_pass("date-range-parens") + self.assertNotIn("unexplained long digit run", result.stdout) + + def test_358_scan_input_write_failure_is_indeterminate(self): + # Before the fix, `{ printf ...; cat ...; } >"$scan_input_file"` had + # no `|| status=$?`, so a failed write was never detected and a + # truncated/empty file was silently fed to the privacy classifier. + self.assert_indeterminate("scan-input-write-failure", "could not assemble complete privacy scan input") + + def test_360_lfs_pointer_requires_binary_attestation(self): + # Before the fix, an added Git LFS pointer (ordinary text, no "GIT + # binary patch" marker) was reconciled as a normal textual change and + # never required a binary-review attestation. + self.assert_blocked("lfs-pointer-binary", "require matching --binary-review-sha and --independent-review-sha") + + def test_365_removed_record_line_total_mismatch_is_flagged(self): + # Before the fix, `[ "$status" = "removed" ] && continue` skipped a + # removed REST record before its line totals were ever compared + # against the diff, so a mismatch went unreported. + self.assert_indeterminate("removed-file-mismatch", "line totals for 'docs/retired.md' differ from REST metadata") + + def test_366_coverage_example_redacts_identifier_shape(self): + # Before the fix, only home-directory paths were redacted from a + # coverage-issue filename before it was echoed into gate output; an + # identifier-shaped filename reached the message verbatim. (The same + # filename is also, independently, real scan input -- path_text is + # always scanned raw -- so this PR is separately and correctly + # BLOCKed by the classifier itself; that is not what this test + # checks. It checks that the *coverage diagnostic message* never + # echoes the raw identifier shape.) + result = self.assert_blocked("coverage-example-redaction", "omits or duplicates 'docs/.md'") + self.assertNotIn("ABCDE1234F", result.stdout) + + def test_373_diff_destination_without_rest_record_is_flagged(self): + # Before the fix, reconciliation only walked REST records looking for + # a matching diff section; a diff section with no REST record at all + # (the reverse direction) was never flagged. + self.assert_indeterminate("diff-extra-destination", "diff destination 'docs/smuggled.md' has no corresponding REST record") + + def test_377_localhost_author_email_is_accepted(self): + # Before the fix, the author/committer email regex required a dotted + # two-letter TLD, so a Git-valid address like dev@localhost rejected + # the whole commit-metadata fetch and the PR went INDETERMINATE. + result = self.assert_pass("author-email-localhost") + self.assertNotIn("could not prove complete head-bound PR commit metadata", result.stdout) + + +class PrivacyScannerFindingsPR335(unittest.TestCase): + """Direct unit coverage of scripts/merge_gate_privacy.py, one pair of + tests (positive + negative) per surviving PR #335 review finding against + the promoted-to-required privacy/PII scanner. Thread ids are the ones + from the PR #335 review; all 11 were investigated and found to already + be fixed on this branch (see the PR description / task report for the + commit-by-commit evidence) -- these tests exist to lock that behavior in + as regression coverage, since no prior test exercised these specific + sub-cases directly. + + Every positive test is paired with a negative test asserting a + legitimate, similarly-shaped value is NOT flagged -- issue #328 was a + phone normalizer that fused adjacent numbers and flagged ordinary date + ranges, so a widened/whole-line/multi-match PII pattern gets a + false-positive check alongside its detection check. + """ + + def setUp(self): + self.privacy = load_privacy_module() + + def blockers(self, text): + return self.privacy.scan(text, HEAD)["blockers"] + + def assert_blocked(self, text, phrase): + blockers = self.blockers(text) + self.assertTrue(any(phrase in b for b in blockers), blockers) + + def assert_not_blocked(self, text): + blockers = self.blockers(text) + self.assertEqual(blockers, [], blockers) + + # Finding 1 -- PRRT_kwDOTWMyis6h8G4J: normalize standard 3-digit-area- + # code Indian landline formats (e.g. 022-23456789, 011 23456789). + def test_finding1_standard_3digit_landline_blocked(self): + self.assert_blocked("Customer landline: 022-23456789", "long digit run") + self.assert_blocked("Customer landline: 011 23456789", "long digit run") + + def test_finding1_negative_no_landline_not_blocked(self): + self.assert_not_blocked("This changelog entry references no landline number at all.") + self.assert_not_blocked( + "Build step 0-1 ran before step 2345 in the pipeline; step 6789 followed." + ) + + # Finding 2 -- PRRT_kwDOTWMyis6h8Z5U: recognize variable-length (4-digit) + # landline area codes (e.g. 0120-2345678). + def test_finding2_variable_length_area_code_blocked(self): + self.assert_blocked("Customer landline: 0120-2345678", "long digit run") + + def test_finding2_negative_non_landline_shapes_not_blocked(self): + # Leading digit isn't 0: not a landline area code. + self.assert_not_blocked("Invoice 9120-2345678 was issued to a vendor.") + # Subscriber half is only 6 digits: one short of the 7-digit form. + self.assert_not_blocked("Ticket 0120-234567 was closed.") + + # Finding 3 -- PRRT_kwDOTWMyis6h8G4L: hold raw certificate payloads + # (bare "-----BEGIN CERTIFICATE-----") for human review. + def test_finding3_bare_certificate_envelope_blocked(self): + self.assert_blocked( + "-----BEGIN CERTIFICATE-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000\n" + "-----END CERTIFICATE-----", + "PEM certificate envelope", + ) + + def test_finding3_negative_plain_mention_not_blocked(self): + self.assert_not_blocked("Renew the TLS certificate before it expires next month.") + + # Finding 4 -- PRRT_kwDOTWMyis6h8Z5W: block trusted-certificate PEM + # envelopes ("-----BEGIN TRUSTED CERTIFICATE-----"), not just bare/X509. + def test_finding4_trusted_certificate_envelope_blocked(self): + self.assert_blocked( + "-----BEGIN TRUSTED CERTIFICATE-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000\n" + "-----END TRUSTED CERTIFICATE-----", + "PEM certificate envelope", + ) + + def test_finding4_negative_non_certificate_pem_not_blocked(self): + # A different PEM envelope kind (not a certificate) is out of this + # finding's scope and must not trip the certificate-specific rule. + self.assert_not_blocked( + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----" + ) + + # Finding 5 -- PRRT_kwDOTWMyis6h8Z5Z: block UUID-shaped credential + # session IDs instead of exempting every UUID wholesale. + def test_finding5_uuid_credential_session_id_blocked(self): + self.assert_blocked( + "credential_session_id: 3fae1c2b-9d4e-4a11-8f2c-7b6d5e4a3c21", + "credential, session, token, or bearer", + ) + + def test_finding5_negative_nil_uuid_sentinel_not_blocked(self): + # The all-zero UUID is an explicit, documented sentinel and must stay + # exempt even in credential context, or every fixture using it as a + # placeholder session id would wrongly block. + self.assert_not_blocked("credential_session_id: 00000000-0000-0000-0000-000000000000") + + # Finding 6 -- PRRT_kwDOTWMyis6h_z-h: block non-UUID credential tokens + # (credential context must not be UUID-only). + def test_finding6_non_uuid_bearer_token_blocked(self): + self.assert_blocked( + "Authorization: Bearer example-live-token-abcdefghijklmnop", + "literal credential, bearer, or API token", + ) + + def test_finding6_negative_env_substitution_not_blocked(self): + self.assert_not_blocked("Authorization: Bearer $BEARER_TOKEN") + + # Finding 7 -- PRRT_kwDOTWMyis6h_z-m: detect customer email addresses in + # added payloads, distinct from admitted commit-author identities. + def test_finding7_customer_email_blocked(self): + self.assert_blocked("Customer contact: jane.doe@customerdomain.test", "customer email shape") + + def test_finding7_negative_example_domain_not_blocked(self): + self.assert_not_blocked("Reviewer contact: dev@example.com") + + # Finding 8 -- PRRT_kwDOTWMyis6iBarl: recognize quoted credential keys + # (e.g. {"api_key": "..."}), not just bare/unquoted assignments. + def test_finding8_quoted_credential_key_blocked(self): + self.assert_blocked( + '{"api_key": "customerproductioncredential"}', + "literal credential, bearer, or API token", + ) + + def test_finding8_negative_quoted_placeholder_not_blocked(self): + self.assert_not_blocked('{"api_key": "your_api_key"}') + + # Finding 9 -- PRRT_kwDOTWMyis6iBarn: stop exempting credential values + # that merely resemble type names (...Token, ...Secret) when lower-case. + def test_finding9_lowercase_typelike_values_not_exempted(self): + self.assert_blocked("access_token: productionToken", "literal credential, bearer, or API token") + self.assert_blocked("client_secret: supersecret", "literal credential, bearer, or API token") + + def test_finding9_negative_real_type_annotation_not_blocked(self): + # Genuine type-syntax spellings (leading-capital ...Token/...Secret, + # or the established lower-case type names) must stay exempt. + self.assert_not_blocked("access_token: AccessToken") + self.assert_not_blocked("client_secret: ClientSecret") + self.assert_not_blocked("client_secret: str") + + # Finding 10 -- PRRT_kwDOTWMyis6iC356: scan password/passphrase/private- + # key assignments as credential literals, not just api_key/token/secret. + def test_finding10_password_assignment_blocked(self): + self.assert_blocked( + '{"password":"customerproductionpassword"}', + "literal credential, bearer, or API token", + ) + + def test_finding10_negative_password_placeholder_not_blocked(self): + self.assert_not_blocked('{"password":"REDACTED"}') + + # Finding 11 -- PRRT_kwDOTWMyis6iC36H: inspect every credential + # assignment on a line, not just the first. + def test_finding11_second_assignment_on_line_inspected(self): + mixed = self.blockers( + '{"api_key":"example_api_key","client_secret":"customerproductionsecret"}' + ) + self.assertEqual(len(mixed), 1, mixed) + self.assertIn("1 literal credential", mixed[0]) + both_real = self.blockers( + '{"api_key":"customerprodkeyabc","client_secret":"customerprodsecretxyz"}' + ) + self.assertEqual(len(both_real), 1, both_real) + self.assertIn("2 literal credential", both_real[0]) + + def test_finding11_negative_all_placeholders_not_blocked(self): + self.assert_not_blocked( + '{"api_key":"your_api_key","client_secret":"replace_me_client_secret"}' + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/merge_gate_diff.py b/scripts/merge_gate_diff.py new file mode 100755 index 000000000..0a6a4adb9 --- /dev/null +++ b/scripts/merge_gate_diff.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Parse the textual portion of a GitHub pull-request diff for merge-gate.""" +from __future__ import annotations + +import json +import sys + + +def remove_prefix(value: str, prefix: str) -> str: + """Python 3.8-compatible equivalent of str.removeprefix.""" + return value[len(prefix):] if value.startswith(prefix) else value + + +def decode_quoted_path(value: str) -> str: + if not (value.startswith('"') and value.endswith('"')): + raise ValueError("expected quoted path") + body = value[1:-1] + raw = bytearray() + index = 0 + while index < len(body): + char = body[index] + if char != "\\": + raw.extend(char.encode("utf-8")) + index += 1 + continue + index += 1 + if index == len(body): + raise ValueError("unterminated escape") + escaped = body[index] + if escaped in '"\\': + raw.append(ord(escaped)) + index += 1 + elif escaped in "abfnrtv": + raw.append({"a": 7, "b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11}[escaped]) + index += 1 + elif escaped in "01234567": + octal = body[index:index + 3] + if len(octal) != 3 or any(char not in "01234567" for char in octal): + raise ValueError("invalid octal escape") + raw.append(int(octal, 8)) + index += 3 + else: + raise ValueError("unsupported escape") + return raw.decode("utf-8") + + +def quoted_token(value: str, start: int) -> tuple[str, int]: + if value[start] != '"': + raise ValueError("expected quote") + index = start + 1 + escaped = False + while index < len(value): + char = value[index] + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + return value[start:index + 1], index + 1 + index += 1 + raise ValueError("unterminated quoted token") + + +def diff_destination(line: str) -> str | None: + value = remove_prefix(line, "diff --git ") + if value.startswith('"'): + _source, index = quoted_token(value, 0) + if index >= len(value) or value[index] != " ": + raise ValueError("missing destination") + destination = value[index + 1:] + path = decode_quoted_path(destination) if destination.startswith('"') else destination + if not path.startswith("b/"): + raise ValueError("destination does not start b/") + return path[2:] + # Git quotes each path independently. A plain source with a quoted + # destination is therefore valid (and occurs for plain-to-Unicode + # renames); the quote itself cannot occur in an unquoted token. + quoted_destination = value.find(' "b/') + if quoted_destination >= 0: + source = value[:quoted_destination] + destination = value[quoted_destination + 1:] + if not source.startswith("a/"): + raise ValueError("invalid unquoted source") + path = decode_quoted_path(destination) + if not path.startswith("b/"): + raise ValueError("destination does not start b/") + return path[2:] + # An unquoted header has no escaping grammar. Splitting on the first + # `` b/`` silently misparses a legal-looking filename containing that + # sequence. Defer an ambiguous header to the independently parsed +++ + # destination; that destination is subsequently reconciled to the REST + # changed-file inventory. Metadata-only ambiguous records remain + # indeterminate because they have no unambiguous textual identity. + parts = value.split(" b/") + if len(parts) != 2: + # Pure mode/deletion records may have neither +++ nor rename-to. A + # same-path header can still be proven when exactly one candidate + # delimiter leaves identical a/ and b/ paths. Do not guess when the + # filename makes that proof ambiguous. + matches: list[str] = [] + start = 0 + while True: + index = value.find(" b/", start) + if index < 0: + break + source = value[:index] + destination = value[index + 1:] + if source.startswith("a/") and destination.startswith("b/") and source[2:] == destination[2:]: + matches.append(destination[2:]) + start = index + 1 + return matches[0] if len(matches) == 1 else None + source, destination = parts + if not source.startswith("a/") or not destination: + raise ValueError("invalid unquoted header") + return destination + + +def textual_destination(line: str) -> str | None: + value = remove_prefix(line, "+++ ") + if value == "/dev/null": + return None + if value.startswith('"'): + path = decode_quoted_path(value) + else: + path = value + if not path.startswith("b/"): + raise ValueError("textual destination does not start b/") + return path[2:] + + +# A Git LFS pointer is committed as ordinary text -- three or four short +# lines naming the spec version, the real object's oid, and its size -- so it +# never triggers Git's own "GIT binary patch" / "Binary files ... differ" +# markers. Its *content* is the only signal that the tracked file is actually +# binary. Treat the presence of the spec-version line in an added hunk as +# binary so it cannot bypass the binary-review attestation gate. +LFS_POINTER_VERSION_LINE = "version https://git-lfs.github.com/spec/v1" + + +def parse(lines: list[str]) -> dict[str, object]: + records: list[dict[str, object]] = [] + added_payload: list[str] = [] + record: dict[str, object] | None = None + + def emit() -> None: + if record is not None: + records.append(record.copy()) + + for raw_line in lines: + # A diff is delimited by LF. CR from CRLF or in an added payload is + # content for scanning/counting, but must not prevent recognising a + # protocol header. + line = raw_line[:-1] if raw_line.endswith("\r") else raw_line + if line.startswith("diff --git "): + emit() + record = { + "destination": diff_destination(line), + "textual_destination": None, + "rename_destination": None, + "added": 0, + "deleted": 0, + "binary": False, + "gitlink": False, + "in_hunk": False, + } + continue + if record is None: + continue + if not record["in_hunk"] and line.startswith("+++ "): + record["textual_destination"] = textual_destination(line) + continue + if not record["in_hunk"] and line.startswith("rename to "): + destination = remove_prefix(line, "rename to ") + record["rename_destination"] = decode_quoted_path(destination) if destination.startswith('"') else destination + continue + if not record["in_hunk"] and line in {"GIT binary patch"} or ( + not record["in_hunk"] and line.startswith("Binary files ") and line.endswith(" differ") + ): + record["binary"] = True + continue + if not record["in_hunk"] and line in { + "old mode 160000", "new mode 160000", "new file mode 160000", + "deleted file mode 160000", + }: + record["gitlink"] = True + continue + if not record["in_hunk"] and line.startswith("index ") and line.endswith(" 160000"): + record["gitlink"] = True + continue + if line.startswith("@@ "): + record["in_hunk"] = True + continue + if record["in_hunk"] and raw_line.startswith("+"): + content = raw_line[1:] + record["added"] = int(record["added"]) + 1 + added_payload.append(content) + if content.rstrip("\r\n") == LFS_POINTER_VERSION_LINE: + record["binary"] = True + elif record["in_hunk"] and raw_line.startswith("-"): + record["deleted"] = int(record["deleted"]) + 1 + emit() + for record in records: + record.pop("in_hunk") + if record["destination"] is None: + if record["textual_destination"] is None and record["rename_destination"] is None: + raise ValueError("ambiguous header without textual destination") + record["destination"] = record["textual_destination"] or record["rename_destination"] + record.pop("rename_destination") + return {"records": records, "added_payload": added_payload} + + +if __name__ == "__main__": + try: + raw = sys.stdin.buffer.read().decode("utf-8") + # Split only on the protocol's LF delimiter. Do not let Python's + # universal-newline mode erase CR or Unicode line-separator payload. + print(json.dumps(parse(raw.split("\n")))) + except (UnicodeError, ValueError) as error: + print(f"merge_gate_diff_error:{error}", file=sys.stderr) + raise SystemExit(2) diff --git a/scripts/merge_gate_privacy.py b/scripts/merge_gate_privacy.py new file mode 100644 index 000000000..54bda0652 --- /dev/null +++ b/scripts/merge_gate_privacy.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Pure privacy classification for ``merge-gate.sh``. + +The program accepts raw, already-complete gate input on stdin and writes only a +small JSON decision record. It deliberately reports counts and categories, +never matched values. UUIDs are recognized before generic hexadecimal digest +masking: a nil UUID is an explicit sentinel, while every other UUID needs +exact, current-head fixture provenance. +""" +from __future__ import print_function + +import argparse +import json +import re +import sys +import unicodedata + + +NIL_UUID = "00000000-0000-0000-0000-000000000000" +UUID_RE = re.compile( + r"(?(?['\"])?" + CREDENTIAL_KEY_RE + + r"(?(key_quote)(?P=key_quote))(?![A-Za-z0-9_-])\s*(?:=|:)\s*)" +) +AUTHORIZATION_BEARER_RE = re.compile( + r"(?i)(?P\bauthorization\s*:\s*bearer(?:\s+)?)(?P.*)$" +) +QUOTED_VALUE_RE = re.compile(r"^(['\"])((?:\\.|(?!\1).)*)\1(?:\s*[,;].*|\s*[}\]])?$") +PLACEHOLDER_VALUE_RE = re.compile( + r"^(?:\*{3,}|(?:redacted|masked|placeholder|example|sample|null|none|n/?a)|" + r"(?:your|replace(?:_me)?|example|sample)[_-](?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|credential|session[_-]?token))$", + re.I, +) +SUBSTITUTION_VALUE_RE = re.compile( + r"^(?:\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^{}\s]+\}|\{\{[^{}\n]+\}\}|" + r"<(?:redacted|masked|token|secret|credential)>|\[(?:redacted|masked)\]|" + r"(?:process\.env\.[A-Za-z_][A-Za-z0-9_]*|os\.environ(?:\.get)?\([^\n]+\)|env\([^\n]+\)))$", + re.I, +) +# Only established type spellings may be lower-case. Keeping the custom-type +# branch case-sensitive prevents ordinary values such as ``productionToken`` +# and ``supersecret`` from being mistaken for annotations. +TYPE_REFERENCE_RE = re.compile(r"^(?:str|string|bytes|secret(?:str)?|token|Optional\[[A-Za-z]+\]|[A-Z][A-Za-z0-9_]*(?:Token|Secret))$") +HOME_RE = re.compile( + r"(^|[^\w])(?:/Users/[^/\s]+|/home/[^/\s]+|/root|[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[^\\/\s]+)" + r"($|/|\\|[^\w.-])", + re.I, +) +PEM_RE = re.compile(r"-----BEGIN\s+(?:(?:X509|TRUSTED)\s+)?CERTIFICATE-----", re.I) +IDENTIFIER_RE = re.compile( + r"\d{2}[A-Z]{5}\d{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[ -]\d{4}[ -][A-Z]|" + r"[A-Z]{5}\d{4}[A-Z]|[6-9]\d{9}", + re.I, +) +LONG_RUN_RE = re.compile(r"\d{11,18}") +PHONE_RE = re.compile(r"(^|[^\w])[6-9](?:[ ()+._-]{0,3}\d){9}($|[^\w])") +LANDLINE_RE = re.compile(r"(^|[^\w])0[1-9]\d[ ._-]\d{4}[ ._-]\d{4}($|[^\w])") +STANDARD_LANDLINE_RE = re.compile( + r"(^|[^\w])0(?:[1-9]\d[ -]\d{8}|[1-9]\d{2}[ -]\d{7})($|[^\w])" +) +GROUPED_NUMBER_RE = re.compile(r"(^|[^\w])\d{4}[ ._-]\d{4}[ ._-]\d{4}(?:[ ._-]\d{4})?($|[^\w])") +DATE_RANGE_RE = re.compile(r"^(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])-20\d{2}\s+(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])-20\d{2}$") + + +def result_record(): + return {"blockers": [], "indeterminate": [], "notes": []} + + +def add(record, bucket, message): + record[bucket].append(message) + + +def load_fixture_provenance(path, head, record): + """Return exact UUIDs authorized by a current-head fixture manifest. + + The manifest is deliberately narrow: a list of objects with only an exact + UUID, the current full head, and ``source: fixture`` qualifies. The gate + currently supplies an empty manifest; this interface exists so a later, + server-bound fixture inventory can authorize values without any text or + path-name heuristic. + """ + if not path: + return set() + try: + with open(path, "r") as source: + values = json.load(source) + except (IOError, ValueError, TypeError): + add(record, "indeterminate", "could not read exact current-head fixture UUID provenance") + return set() + if not isinstance(values, list): + add(record, "indeterminate", "fixture UUID provenance was malformed") + return set() + allowed = set() + for value in values: + if not isinstance(value, dict): + add(record, "indeterminate", "fixture UUID provenance was malformed") + return set() + uuid = value.get("uuid") + if not isinstance(uuid, str) or not UUID_RE.fullmatch(uuid): + add(record, "indeterminate", "fixture UUID provenance was malformed") + return set() + if value.get("head") == head and value.get("source") == "fixture": + allowed.add(uuid.lower()) + return allowed + + +def tokenize_uuids(text, allowed, record): + """Classify UUIDs before masking generic hexadecimal shapes.""" + retained = [] + unknown = 0 + credential = 0 + for line in text.splitlines(True): + cursor = 0 + pieces = [] + for match in UUID_RE.finditer(line): + pieces.append(line[cursor:match.start()]) + value = match.group(0).lower() + if value == NIL_UUID: + pieces.append("") + elif CREDENTIAL_CONTEXT_RE.search(line): + credential += 1 + pieces.append("") + elif value in allowed: + pieces.append("") + else: + unknown += 1 + pieces.append("") + cursor = match.end() + pieces.append(line[cursor:]) + retained.append("".join(pieces)) + if credential: + add(record, "blockers", "privacy scan found %d non-nil UUID shape(s) in credential, session, token, or bearer context" % credential) + if unknown: + add(record, "indeterminate", "privacy scan found %d non-nil UUID shape(s) without exact current-head fixture provenance" % unknown) + return "".join(retained) + + +def credential_value_status(value): + """Classify an explicitly assigned credential value without returning it.""" + value = value.strip() + if not value: + return "indeterminate" + quoted = QUOTED_VALUE_RE.match(value) + if value[0:1] in ("'", '"') and not quoted: + return "indeterminate" + if quoted: + value = quoted.group(2) + else: + value = re.split(r"[\s,;#]", value, 1)[0] + if not value: + return "indeterminate" + if PLACEHOLDER_VALUE_RE.fullmatch(value) or SUBSTITUTION_VALUE_RE.fullmatch(value) or TYPE_REFERENCE_RE.fullmatch(value): + return "placeholder" + return "blocker" + + +def credential_value_span(text, start): + """Return one assignment value's bounds without consuming later fields.""" + if start >= len(text): + return start, start + if text[start] in ("'", '"'): + quote = text[start] + cursor = start + 1 + while cursor < len(text): + if text[cursor] == "\\": + cursor += 2 + elif text[cursor] == quote: + return start, cursor + 1 + else: + cursor += 1 + return start, len(text) + cursor = start + while cursor < len(text) and text[cursor] not in "\t\r\n ,;#": + cursor += 1 + return start, cursor + + +def tokenize_credential_literals(text, record): + """Block assigned secret material before UUID/digest masking can hide it.""" + blocked = 0 + malformed = 0 + retained = [] + cursor = 0 + while cursor < len(text): + bearer = AUTHORIZATION_BEARER_RE.search(text, cursor) + assignment = CREDENTIAL_ASSIGNMENT_RE.search(text, cursor) + match = min((candidate for candidate in (bearer, assignment) if candidate), key=lambda candidate: candidate.start(), default=None) + if not match: + retained.append(text[cursor:]) + break + value_start = match.end("prefix") + value_start, value_end = credential_value_span(text, value_start) + status = credential_value_status(text[value_start:value_end]) + retained.append(text[cursor:value_start]) + if status == "placeholder": + retained.append(text[value_start:value_end]) + else: + retained.append("") + if status == "blocker": + blocked += 1 + else: + malformed += 1 + cursor = value_end + if blocked: + add(record, "blockers", "privacy scan found %d literal credential, bearer, or API token value(s)" % blocked) + if malformed: + add(record, "indeterminate", "privacy scan found %d malformed or truncated credential assignment(s)" % malformed) + return "".join(retained) + + +def customer_email_count(text): + count = 0 + for match in EMAIL_RE.finditer(text): + if match.group(1).lower() not in EXAMPLE_EMAIL_DOMAINS: + count += 1 + return count + + +def nonplaceholder_count(pattern, text): + count = 0 + for match in pattern.finditer(text): + value = match.group(0).upper() + compact = value.replace(" ", "").replace("-", "") + if not PLACEHOLDER_RE.fullmatch(compact): + count += 1 + return count + + +def normalize_whitespace(text): + return "".join(" " if char == "\t" or unicodedata.category(char) == "Zs" else char for char in text) + + +def grouped_numbers(text): + values = [] + for match in GROUPED_NUMBER_RE.finditer(text): + # group(0) includes the single boundary character captured by each of + # the regex's leading/trailing `(^|[^\w])` anchors (0 or 1 char each, + # e.g. a wrapping paren or a trailing comma/period). Strip exactly + # those captured characters -- not just whitespace -- before testing + # the compact-date exclusion, or a parenthesized/punctuated date range + # such as "(0101-2026 0201-2026)" never matches DATE_RANGE_RE and is + # misreported as an unexplained long digit run. + whole = match.group(0) + lead = len(match.group(1)) + trail = len(match.group(2)) + value = whole[lead:len(whole) - trail] + if not DATE_RANGE_RE.fullmatch(value): + values.append(value) + return "\n".join(values) + + +def redact_shapes(text): + """Mask the classifier's own identifier/digest/email shapes in-place. + + Used to sanitize a bounded diagnostic example (for example, a filename + echoed into a merge-gate coverage message) before it leaves the gate, so + the example itself cannot carry a home-directory path, identifier, long + digit run, digest/UUID, or email shape into gate output. + """ + text = HOME_RE.sub(lambda m: m.group(1) + "" + m.group(2), text) + text = UUID_OR_DIGEST_RE.sub("", text) + text = IDENTIFIER_RE.sub("", text) + text = LONG_RUN_RE.sub("", text) + text = EMAIL_RE.sub("", text) + return text + + +def scan(text, head, fixture_provenance=None): + record = result_record() + allowed = load_fixture_provenance(fixture_provenance, head, record) + + home_count = len(HOME_RE.findall(text)) + if home_count: + add(record, "blockers", "privacy scan found %d developer-home path shape(s)" % home_count) + pem_count = len(PEM_RE.findall(text)) + if pem_count: + add(record, "blockers", "privacy scan found %d PEM certificate envelope(s)" % pem_count) + + credential_tokenized = tokenize_credential_literals(text, record) + email_count = customer_email_count(credential_tokenized) + if email_count: + add(record, "blockers", "privacy scan found %d customer email shape(s)" % email_count) + + uuid_tokenized = tokenize_uuids(credential_tokenized, allowed, record) + redacted = UUID_OR_DIGEST_RE.sub("", uuid_tokenized) + exempt = len(UUID_OR_DIGEST_RE.findall(uuid_tokenized)) + if exempt: + add(record, "notes", "%d added/path line(s) carried generated UUID/digest shapes; inspect those lines" % exempt) + + normalized = normalize_whitespace(redacted) + phone = "\n".join(match.group(0) for match in PHONE_RE.finditer(normalized)) + landline = "\n".join(match.group(0) for match in LANDLINE_RE.finditer(normalized)) + standard_landline = "\n".join(match.group(0) for match in STANDARD_LANDLINE_RE.finditer(normalized)) + shapes = "\n".join(( + redacted, + re.sub(r"\D", "", phone), + re.sub(r"\D", "", landline), + re.sub(r"\D", "", standard_landline), + re.sub(r"\D", "", grouped_numbers(redacted)), + )) + hits = nonplaceholder_count(IDENTIFIER_RE, shapes) + runs = nonplaceholder_count(LONG_RUN_RE, shapes) + if hits or runs: + add(record, "blockers", "privacy scan found %d identifier shape(s) and %d unexplained long digit run(s)" % (hits, runs)) + else: + add(record, "notes", "PR metadata, destination paths, and payload lines carry no identifier shapes") + return record + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--head") + parser.add_argument("--fixture-provenance") + parser.add_argument( + "--redact-shapes", action="store_true", + help="Redact identifier/digest/home-path/email shapes from stdin and " + "print the result. No classification; no --head required.", + ) + args = parser.parse_args(argv) + if args.redact_shapes: + try: + text = sys.stdin.read() + except UnicodeError: + return 0 + sys.stdout.write(redact_shapes(text)) + return 0 + if not args.head or not re.fullmatch(r"[0-9A-Fa-f]{40}", args.head): + parser.error("--head must be a full 40-hex commit SHA") + try: + text = sys.stdin.read() + except UnicodeError: + print(json.dumps({"blockers": [], "indeterminate": ["could not read privacy scan input"], "notes": []})) + return 0 + print(json.dumps(scan(text, args.head.lower(), args.fixture_provenance), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/merge_gate_test_gh.py b/scripts/merge_gate_test_gh.py new file mode 100755 index 000000000..3c489fe66 --- /dev/null +++ b/scripts/merge_gate_test_gh.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Offline GitHub CLI fixture for the shrunk ``merge-gate.test.py``. + +Replaces ``merge_gate_fake_gh.py`` (deleted: it existed only to test the +identity-binding/PR-body/skipped-CI concerns that were cut). This double +covers only what the shrunk ``merge-gate.sh`` still calls: PR metadata, the +diff, the changed-files list, the compatibility surface, reviews, and issue +comments. +""" +import base64 +import json +import os +import sys + +args = sys.argv[1:] +scenario = os.environ.get("GATE_SCENARIO", "pass") + +HEAD = "0123456789abcdef0123456789abcdef01234567" +SHORT = HEAD[:7] +BASE_TIP = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +DIGEST = "a" * 64 + + +def emit(value): + print(value if isinstance(value, str) else json.dumps(value)) + + +def fail(message="controlled API failure"): + print(message, file=sys.stderr) + raise SystemExit(1) + + +def b64(obj_or_text): + text = obj_or_text if isinstance(obj_or_text, str) else json.dumps(obj_or_text) + return base64.b64encode(text.encode()).decode() + + +# --------------------------------------------------------------------------- +# Per-scenario fixture state. Every scenario starts from DEFAULT and overrides +# only what it needs to exercise. +# --------------------------------------------------------------------------- +DEFAULT_FILES = [{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}] +DEFAULT_DIFF = ( + "diff --git a/docs/example.md b/docs/example.md\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/docs/example.md\n" + "@@ -0,0 +1 @@\n" + "+Safe content line.\n" +) +DEFAULT_SURFACE = {"schema_version": 1, "manifest_sha256": DIGEST, + "files": [{"path": "src/example.rs", "sha256": DIGEST}]} +DEFAULT_REVIEWS = [{"user": {"login": "reviewer", "type": "User"}, "commit_id": HEAD, "state": "COMMENTED"}] +DEFAULT_COMMENTS = [] +DEFAULT_BODY = "## Outcome and reason\n\nA bounded merge preflight.\n" +DEFAULT_COMMIT = { + "sha": HEAD, + "commit": { + "message": "safe commit metadata", + "author": {"name": "Maintainer", "email": "maintainer@example.invalid"}, + "committer": {"name": "Maintainer", "email": "maintainer@example.invalid"}, + }, + "author": {"login": "author"}, + "committer": {"login": "author"}, +} + + +def state(): + s = { + "title": "Safe merge gate control", + "body": DEFAULT_BODY, + "changed_files_expected": 1, + "files": list(DEFAULT_FILES), + "diff": DEFAULT_DIFF, + "surface_head": dict(DEFAULT_SURFACE), + "surface_base": dict(DEFAULT_SURFACE), + "surface_head_fail": False, + "surface_head_malformed": False, + "reviews": list(DEFAULT_REVIEWS), + "comments": list(DEFAULT_COMMENTS), + "commits": [dict(DEFAULT_COMMIT)], + } + + if scenario == "surface-fetch-fail": + s["surface_head_fail"] = True + + elif scenario == "surface-malformed": + s["surface_head_malformed"] = True + + elif scenario == "surface-unpins": + s["surface_base"] = {"schema_version": 1, "manifest_sha256": DIGEST, "files": [ + {"path": "src/example.rs", "sha256": DIGEST}, + {"path": "docs/tally/compatibility/compatibility-surface.json", "sha256": DIGEST}, + ]} + + elif scenario == "surface-reseal-missing": + s["files"] = [{"filename": "src/example.rs", "status": "modified", "additions": 1, "deletions": 1}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/src/example.rs b/src/example.rs\n" + "index 1111111..2222222 100644\n" + "--- a/src/example.rs\n" + "+++ b/src/example.rs\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + ) + + elif scenario == "surface-reseal-present": + s["files"] = [ + {"filename": "src/example.rs", "status": "modified", "additions": 1, "deletions": 1}, + {"filename": "docs/tally/compatibility/compatibility-surface.json", "status": "modified", "additions": 1, "deletions": 1}, + ] + s["changed_files_expected"] = 2 + s["diff"] = ( + "diff --git a/src/example.rs b/src/example.rs\n" + "index 1111111..2222222 100644\n" + "--- a/src/example.rs\n" + "+++ b/src/example.rs\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + "diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json\n" + "index 1111111..2222222 100644\n" + "--- a/docs/tally/compatibility/compatibility-surface.json\n" + "+++ b/docs/tally/compatibility/compatibility-surface.json\n" + "@@ -1 +1 @@\n" + "-old manifest\n" + "+new manifest\n" + ) + + elif scenario == "privacy-email-blocker": + s["title"] = "Customer contact: customer@company.test" + + elif scenario == "privacy-credential-blocker": + s["diff"] = ( + "diff --git a/config/settings.py b/config/settings.py\n" + "index 1111111..2222222 100644\n" + "--- a/config/settings.py\n" + "+++ b/config/settings.py\n" + "@@ -1 +1 @@\n" + "-api_key = \"placeholder\"\n" + "+api_key = \"sk_live_abcdef1234567890abcdef\"\n" + ) + s["files"] = [{"filename": "config/settings.py", "status": "modified", "additions": 1, "deletions": 1}] + + elif scenario == "privacy-uuid-indeterminate": + s["diff"] = ( + "diff --git a/docs/example.md b/docs/example.md\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/docs/example.md\n" + "@@ -0,0 +1 @@\n" + "+fixture reference 11111111-2222-3333-4444-555555555555\n" + ) + + elif scenario in {"binary-missing-attestation", "binary-with-attestation"}: + s["files"] = [{"filename": "docs/new.png", "status": "added", "additions": 0, "deletions": 0}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/docs/new.png b/docs/new.png\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "GIT binary patch\n" + "literal 3\nQwerty\n" + ) + + elif scenario == "gitlink-change": + s["files"] = [{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/vendor/module b/vendor/module\n" + "index 1111111..2222222 160000\n" + "--- a/vendor/module\n" + "+++ b/vendor/module\n" + "@@ -1 +1 @@\n" + "-Subproject commit 1111111111111111111111111111111111111111\n" + "+Subproject commit 2222222222222222222222222222222222222222\n" + ) + + elif scenario == "review-names-head-via-review": + s["reviews"] = [{"user": {"login": "reviewer", "type": "User"}, "commit_id": HEAD, "state": "APPROVED"}] + + elif scenario == "review-names-head-via-comment": + s["reviews"] = [] + s["comments"] = [{"user": {"login": "bot", "type": "Bot"}, + "body": f"codex-pull-request-review-summary\n| 📝 | ✅ **Completed** | `{SHORT}` |"}] + + elif scenario == "review-missing": + s["reviews"] = [] + s["comments"] = [] + + elif scenario == "date-range-parens": + s["body"] = DEFAULT_BODY + "\nSprint window (0101-2026 0201-2026) is locked.\n" + + elif scenario == "scan-input-write-failure": + pass # handled entirely by the fake mktemp wrapper; fixtures stay default + + elif scenario == "lfs-pointer-binary": + s["files"] = [{"filename": "assets/logo.psd", "status": "added", "additions": 3, "deletions": 0}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/assets/logo.psd b/assets/logo.psd\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/assets/logo.psd\n" + "@@ -0,0 +1,3 @@\n" + "+version https://git-lfs.github.com/spec/v1\n" + "+oid sha256:" + "b" * 64 + "\n" + "+size 12345\n" + ) + + elif scenario == "removed-file-mismatch": + s["files"] = [{"filename": "docs/retired.md", "status": "removed", "additions": 0, "deletions": 5}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/docs/retired.md b/docs/retired.md\n" + "deleted file mode 100644\n" + "index 1111111..0000000\n" + "--- a/docs/retired.md\n" + "+++ /dev/null\n" + "@@ -1,2 +0,0 @@\n" + "-line one\n" + "-line two\n" + ) + + elif scenario == "diff-extra-destination": + s["files"] = [{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}] + s["changed_files_expected"] = 1 + s["diff"] = DEFAULT_DIFF + ( + "diff --git a/docs/smuggled.md b/docs/smuggled.md\n" + "new file mode 100644\n" + "index 0000000..3333333\n" + "--- /dev/null\n" + "+++ b/docs/smuggled.md\n" + "@@ -0,0 +1 @@\n" + "+smuggled content\n" + ) + + elif scenario == "coverage-example-redaction": + # The diff has no section at all for this REST filename, so it is + # reported as a coverage issue; the filename itself carries an + # identifier shape and must be redacted before it reaches gate output. + s["files"] = [{"filename": "docs/ABCDE1234F.md", "status": "added", "additions": 1, "deletions": 0}] + s["changed_files_expected"] = 1 + s["diff"] = ( + "diff --git a/unrelated.md b/unrelated.md\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/unrelated.md\n" + "@@ -0,0 +1 @@\n" + "+content\n" + ) + + elif scenario == "author-email-localhost": + commit = dict(DEFAULT_COMMIT) + commit["commit"] = { + "message": "safe commit metadata", + "author": {"name": "Developer", "email": "dev@localhost"}, + "committer": {"name": "Developer", "email": "dev@localhost"}, + } + s["commits"] = [commit] + + return s + + +S = state() + + +def has(*needles): + joined = " ".join(args) + return all(n in joined for n in needles) + + +if args[:2] == ["pr", "view"]: + emit({"headRefOid": HEAD, "baseRefName": "master", "title": S["title"], "body": S["body"], + "changedFiles": S["changed_files_expected"]}) + +elif args[:2] == ["pr", "diff"]: + emit(S["diff"]) + +elif args and args[0] == "api": + joined = " ".join(args) + if "/contents/" in joined: + if "docs/tally/compatibility/compatibility-surface.json" in joined: + if f"ref={HEAD}" in joined: + if S["surface_head_fail"]: + fail("controlled surface read failure") + if S["surface_head_malformed"]: + emit({"content": "not-base64"}) + else: + emit({"encoding": "base64", "content": b64(S["surface_head"])}) + else: + emit({"encoding": "base64", "content": b64(S["surface_base"])}) + else: + fail("unknown contents fixture") + elif "/pulls/321/commits" in joined: + emit([S["commits"]]) + elif any(a.endswith("/pulls/321") for a in args): + emit({"commits": len(S["commits"]), "head": {"sha": HEAD}}) + elif "/pulls/321/files" in joined: + emit([S["files"]]) + elif "/pulls/321/reviews" in joined: + emit([S["reviews"]]) + elif "/issues/321/comments" in joined: + emit([S["comments"]]) + elif "branches/master" in joined: + emit(BASE_TIP) + else: + fail("unknown API fixture") +else: + fail("unknown command fixture")