diff --git a/.gitea/scripts/audit-force-merge.sh b/.gitea/scripts/audit-force-merge.sh new file mode 100644 index 0000000..2c2ee6c --- /dev/null +++ b/.gitea/scripts/audit-force-merge.sh @@ -0,0 +1,132 @@ +#!/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. +# +# Triggers on `pull_request_target: closed`. +# Required env: GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS + +set -euo pipefail + +: "${GITEA_TOKEN:?required}" +: "${GITEA_HOST:?required}" +: "${REPO:?required}" +: "${PR_NUMBER:?required}" +if [ -z "${REQUIRED_CHECKS_JSON:-}" ] && [ -z "${REQUIRED_CHECKS:-}" ]; then + echo "::error::Either REQUIRED_CHECKS_JSON or REQUIRED_CHECKS must be set" + exit 1 +fi + +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +API="https://${GITEA_HOST}/api/v1" +AUTH="Authorization: token ${GITEA_TOKEN}" + +# 1. Fetch the PR. Fail-closed: verify HTTP 200. +PR_TMP=$(mktemp) +PR_HTTP=$(curl -sS -o "$PR_TMP" -w '%{http_code}' -H "$AUTH" \ + "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}") +PR=$(cat "$PR_TMP") +rm -f "$PR_TMP" +if [ "$PR_HTTP" != "200" ]; then + echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${PR_HTTP} — cannot evaluate merge state." + exit 1 +fi + +PR_SCHEMA_OK=$(echo "$PR" | jq -r ' + (.merged | type == "boolean") and + (.merge_commit_sha | type == "string") and + (.merged_by | type == "object") and (.merged_by.login | type == "string") and + (.base | type == "object") and (.base.ref | type == "string") and + (.head | type == "object") and (.head.sha | type == "string") +') +if [ "$PR_SCHEMA_OK" != "true" ]; then + echo "::error::GET /pulls/${PR_NUMBER} returned HTTP 200 but one or more required fields are missing, null, or of wrong type — cannot evaluate force-merge." + exit 1 +fi + +MERGED=$(echo "$PR" | jq -r '.merged') +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') +MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login') +TITLE=$(echo "$PR" | jq -r '.title // ""') +BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref') +HEAD_SHA=$(echo "$PR" | jq -r '.head.sha') + +# 2. Required status checks — branch-aware JSON dict takes precedence. +if [ -n "${REQUIRED_CHECKS_JSON:-}" ]; then + _RC_JSON_OK=$(echo "$REQUIRED_CHECKS_JSON" | jq -r --arg branch "$BASE_BRANCH" ' + has($branch) and (.[$branch] | type == "array") + ') + if [ "$_RC_JSON_OK" != "true" ]; then + echo "::error::REQUIRED_CHECKS_JSON missing or non-array entry for branch '$BASE_BRANCH' — cannot evaluate required checks." + exit 1 + fi + REQUIRED=$(echo "$REQUIRED_CHECKS_JSON" | jq -r --arg branch "$BASE_BRANCH" '.[$branch] | .[]') +else + REQUIRED="$REQUIRED_CHECKS" +fi +if [ -z "${REQUIRED//[[:space:]]/}" ]; then + echo "::notice::REQUIRED_CHECKS empty for branch '$BASE_BRANCH' — force-merge not applicable." + exit 0 +fi + +# 3. Status-check state at the PR HEAD. Fail-closed: verify HTTP 200. +STATUS_TMP=$(mktemp) +STATUS_HTTP=$(curl -sS -o "$STATUS_TMP" -w '%{http_code}' -H "$AUTH" \ + "${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status") +STATUS=$(cat "$STATUS_TMP") +rm -f "$STATUS_TMP" +if [ "$STATUS_HTTP" != "200" ]; then + echo "::error::GET /commits/${HEAD_SHA}/status returned HTTP ${STATUS_HTTP} — cannot evaluate required checks." + exit 1 +fi +if ! echo "$STATUS" | jq -e '(.statuses | type) == "array"' >/dev/null; then + echo "::error::GET /commits/${HEAD_SHA}/status returned HTTP 200 but 'statuses' is missing or not an array — cannot evaluate required checks." + exit 1 +fi + +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? +FAILED_CHECKS=() +while IFS= read -r req; do + trimmed="${req#"${req%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [ -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 .) + +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/scripts/tests/test_audit_force_merge.sh b/.gitea/scripts/tests/test_audit_force_merge.sh new file mode 100644 index 0000000..dd08d77 --- /dev/null +++ b/.gitea/scripts/tests/test_audit_force_merge.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# test_audit_force_merge.sh — regression lock for mcp-server audit-force-merge +# fail-closed behavior. Verifies schema validation paths via direct jq. + +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "PASS: $*"; } + +[ -x "$(command -v jq)" ] || { echo "SKIP: jq not on PATH"; exit 0; } + +validate_pr_schema() { + jq -r ' + (.merged | type == "boolean") and + (.merge_commit_sha | type == "string") and + (.merged_by | type == "object") and (.merged_by.login | type == "string") and + (.base | type == "object") and (.base.ref | type == "string") and + (.head | type == "object") and (.head.sha | type == "string") + ' +} + +validate_statuses_type() { + jq -r '(.statuses | type) == "array"' +} + +validate_required_checks_json() { + local branch="$1" + local json="$2" + echo "$json" | jq -r --arg branch "$branch" 'has($branch) and (.[$branch] | type == "array")' +} + +# PR schema tests +T1=$(echo '{"merged":true,"merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T1" = "true" ] || fail "T1: valid payload should pass schema" +pass "T1: valid payload passes schema" + +T2=$(echo '{"merged":"true","merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T2" = "false" ] || fail "T2: merged as string should fail schema" +pass "T2: merged as string fails schema" + +T3=$(echo '{"merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T3" = "false" ] || fail "T3: missing merged should fail schema" +pass "T3: missing merged fails schema" + +# Statuses type tests +T4=$(echo '{"statuses":[{"context":"c1","status":"success"}]}' | validate_statuses_type) +[ "$T4" = "true" ] || fail "T4: array statuses should pass" +pass "T4: array statuses passes" + +T5=$(echo '{"statuses":null}' | validate_statuses_type) +[ "$T5" = "false" ] || fail "T5: null statuses should fail" +pass "T5: null statuses fails" + +T6=$(echo '{}' | validate_statuses_type) +[ "$T6" = "false" ] || fail "T6: missing statuses should fail" +pass "T6: missing statuses fails" + +# REQUIRED_CHECKS_JSON tests +T7=$(validate_required_checks_json "main" '{"main":["CI"]}') +[ "$T7" = "true" ] || fail "T7: existing array branch should pass" +pass "T7: existing array branch passes" + +T8=$(validate_required_checks_json "staging" '{"main":["CI"]}') +[ "$T8" = "false" ] || fail "T8: missing branch should fail" +pass "T8: missing branch fails" + +T9=$(validate_required_checks_json "main" '{"main":"CI"}') +[ "$T9" = "false" ] || fail "T9: string branch entry should fail" +pass "T9: string branch entry fails" + +echo +echo "ALL AUDIT-FORCE-MERGE CHECKS PASSED" diff --git a/.gitea/workflows/audit-force-merge.yml b/.gitea/workflows/audit-force-merge.yml new file mode 100644 index 0000000..f364291 --- /dev/null +++ b/.gitea/workflows/audit-force-merge.yml @@ -0,0 +1,37 @@ +name: audit-force-merge +# Detect a §SOP-6 force-merge after PR close and emit structured audit JSON. +# Runs on base branch (pull_request_target) so secrets are available. + +on: + pull_request_target: + types: [closed] + +jobs: + audit: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout base (for scripts) + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Detect force-merge + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_HOST: ${{ github.server_url | replace('https://', '') }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Required-status-check contexts to evaluate at merge time. + # Branch-aware JSON dict: keys are protected branch names, + # values are arrays of context names that branch protection + # requires for that branch. Mirror this against branch + # protection settings for each branch listed here. + REQUIRED_CHECKS_JSON: | + { + "main": [ + "CI / test (pull_request)" + ] + } + run: | + bash .gitea/scripts/audit-force-merge.sh