From 6818f01447bd49ea197fae51e7c1919840f14de1 Mon Sep 17 00:00:00 2001 From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)" Date: Fri, 8 May 2026 20:09:35 -0700 Subject: [PATCH] =?UTF-8?q?ci(audit-force-merge):=20fan=20=C2=A7SOP-6=20fo?= =?UTF-8?q?rce-merge=20audit=20to=20molecule-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the canonical workflow shipped on internal#120 + #122. Same shape: pull_request_target on closed, base.sha checkout, structured JSON event to runner stdout that Vector ships to Loki on molecule-canonical-obs. REQUIRED_CHECKS env declares both molecule-core/main protected contexts (sop-tier-check + Secret scan). Mirror against branch protection if either is added/removed. Verified end-to-end on internal: synthetic force-merge of internal#123 emitted incident.force_merge with all expected fields, indexable in Loki via {host="molecule-canonical-1"} |= "incident.force_merge". Tier: low (CI workflow, no platform code path). --- .gitea/scripts/audit-force-merge.sh | 118 +++++++++++++++++++++++++ .gitea/workflows/audit-force-merge.yml | 58 ++++++++++++ 2 files changed, 176 insertions(+) create mode 100755 .gitea/scripts/audit-force-merge.sh create mode 100644 .gitea/workflows/audit-force-merge.yml diff --git a/.gitea/scripts/audit-force-merge.sh b/.gitea/scripts/audit-force-merge.sh new file mode 100755 index 00000000..d2c34fe3 --- /dev/null +++ b/.gitea/scripts/audit-force-merge.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# audit-force-merge — detect a §SOP-6 force-merge after PR close, emit +# `incident.force_merge` to stdout as structured JSON. +# +# Vector's docker_logs source picks up runner stdout; the JSON gets +# shipped to Loki on molecule-canonical-obs, indexable by event_type. +# Query example: +# +# {host="operator"} |= "event_type" |= "incident.force_merge" | json +# +# A force-merge is detected when a PR closed-with-merged=true had at +# least one of the repo's required-status-check contexts in a state +# other than "success" at the merge commit's SHA. That's exactly what +# the Gitea force_merge:true API call lets through, so it's a faithful +# detector of the override path. +# +# Triggers on `pull_request_target: closed` (loaded from base branch +# per §SOP-6 security model). No-op when merged=false. +# +# Required env (set by the workflow): +# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS +# +# REQUIRED_CHECKS is a newline-separated list of status-check context +# names that branch protection requires. Declared in the workflow YAML +# rather than fetched from /branch_protections (which needs admin +# scope — sop-tier-bot has read-only). Trade dynamism for simplicity: +# when the required-check set changes, update both branch protection +# AND this env. Keeping them in sync is less complexity than granting +# the audit bot admin perms on every repo. + +set -euo pipefail + +: "${GITEA_TOKEN:?required}" +: "${GITEA_HOST:?required}" +: "${REPO:?required}" +: "${PR_NUMBER:?required}" +: "${REQUIRED_CHECKS:?required (newline-separated context names)}" + +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +API="https://${GITEA_HOST}/api/v1" +AUTH="Authorization: token ${GITEA_TOKEN}" + +# 1. Fetch the PR. If not merged, no-op. +PR=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}") +MERGED=$(echo "$PR" | jq -r '.merged // false') +if [ "$MERGED" != "true" ]; then + echo "::notice::PR #${PR_NUMBER} closed without merge — no audit emission." + exit 0 +fi + +MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty') +MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"') +TITLE=$(echo "$PR" | jq -r '.title // ""') +BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"') +HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty') + +if [ -z "$MERGE_SHA" ]; then + echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge." + exit 0 +fi + +# 2. Required status checks declared in the workflow env. +REQUIRED="$REQUIRED_CHECKS" +if [ -z "${REQUIRED//[[:space:]]/}" ]; then + echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable." + exit 0 +fi + +# 3. Status-check state at the PR HEAD (where checks ran). The merge +# commit doesn't get its own checks; we evaluate the PR's last +# commit, which is what branch protection compared against. +STATUS=$(curl -sS -H "$AUTH" \ + "${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status") +declare -A CHECK_STATE +while IFS=$'\t' read -r ctx state; do + [ -n "$ctx" ] && CHECK_STATE[$ctx]="$state" +done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"') + +# 4. For each required check, was it green at merge? YAML block scalars +# (`|`) leave a trailing newline; skip blank/whitespace-only lines. +FAILED_CHECKS=() +while IFS= read -r req; do + trimmed="${req#"${req%%[![:space:]]*}"}" # ltrim + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rtrim + [ -z "$trimmed" ] && continue + state="${CHECK_STATE[$trimmed]:-missing}" + if [ "$state" != "success" ]; then + FAILED_CHECKS+=("${trimmed}=${state}") + fi +done <<< "$REQUIRED" + +if [ "${#FAILED_CHECKS[@]}" -eq 0 ]; then + echo "::notice::PR #${PR_NUMBER} merged with all required checks green — not a force-merge." + exit 0 +fi + +# 5. Emit structured audit event. +NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) +FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) + +# Print as a single-line JSON so Vector's parse_json transform can pick +# it up cleanly from docker_logs. +jq -nc \ + --arg event_type "incident.force_merge" \ + --arg ts "$NOW" \ + --arg repo "$REPO" \ + --argjson pr "$PR_NUMBER" \ + --arg title "$TITLE" \ + --arg base "$BASE_BRANCH" \ + --arg merged_by "$MERGED_BY" \ + --arg merge_sha "$MERGE_SHA" \ + --argjson failed_checks "$FAILED_JSON" \ + '{event_type: $event_type, ts: $ts, repo: $repo, pr: $pr, title: $title, + base_branch: $base, merged_by: $merged_by, merge_sha: $merge_sha, + failed_checks: $failed_checks}' + +echo "::warning::FORCE-MERGE detected on PR #${PR_NUMBER} by ${MERGED_BY}: ${#FAILED_CHECKS[@]} required check(s) not green at merge time." diff --git a/.gitea/workflows/audit-force-merge.yml b/.gitea/workflows/audit-force-merge.yml new file mode 100644 index 00000000..09f4eb7b --- /dev/null +++ b/.gitea/workflows/audit-force-merge.yml @@ -0,0 +1,58 @@ +# audit-force-merge — emit `incident.force_merge` to runner stdout when +# a PR is merged with required-status-checks not green. Vector picks +# the JSON line off docker_logs and ships to Loki on +# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as: +# +# {host="operator"} |= "event_type" |= "incident.force_merge" | json +# +# Closes the §SOP-6 audit gap (the doc says force-merges write to +# `structure_events`, but that table lives in the platform DB, not +# Gitea-side; Loki is the practical equivalent for Gitea Actions +# events). When the credential / observability stack converges later, +# this can sync into structure_events from Loki via a backfill job — +# the structured JSON shape is forward-compatible. +# +# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script- +# extract pattern as sop-tier-check. + +name: audit-force-merge + +# pull_request_target loads from the base branch — same security model +# as sop-tier-check. Without this, an attacker could rewrite the +# workflow on a PR and skip the audit emission for their own +# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full +# rationale. +on: + pull_request_target: + types: [closed] + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + # Skip when PR is closed without merge — saves a runner. + if: github.event.pull_request.merged == true + steps: + - name: Check out base branch (for the script) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Detect force-merge + emit audit event + env: + # Same org-level secret the sop-tier-check workflow uses. + GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Required-status-check contexts to evaluate at merge time. + # Newline-separated. Mirror this against branch protection + # (settings → branches → protected branch → required checks). + # Declared here rather than fetched from /branch_protections + # because that endpoint requires admin write — sop-tier-bot is + # read-only by design (least-privilege). + REQUIRED_CHECKS: | + sop-tier-check / tier-check (pull_request) + Secret scan / Scan diff for credential-shaped strings (pull_request) + run: bash .gitea/scripts/audit-force-merge.sh