From da01cedd25611b752c4a0730bb2cc7d1b2537583 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:43:33 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20implement=20issue=20#800=20?= =?UTF-8?q?=E2=80=94=20Story:=20BACKFILL=20+=20REMEDIATE=20=E2=80=94=20bri?= =?UTF-8?q?ng=20the=20fleet=20to=20green=20on=20the=20gitignore=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/gitignore-baseline-tests.yml | 15 +- scripts/compliance-remediate.sh | 117 ++++++++- .../gitignore-baseline.bats | 235 ++++++++++++++++++ .../routing-messages.bats | 29 +-- 4 files changed, 366 insertions(+), 30 deletions(-) create mode 100644 test/scripts/compliance-remediate/gitignore-baseline.bats diff --git a/.github/workflows/gitignore-baseline-tests.yml b/.github/workflows/gitignore-baseline-tests.yml index 5ba664414..5bb5db5fc 100644 --- a/.github/workflows/gitignore-baseline-tests.yml +++ b/.github/workflows/gitignore-baseline-tests.yml @@ -4,13 +4,16 @@ # - scripts/lib/gitignore-baseline.sh # - scripts/sync-gitignore-baseline.sh # - scripts/seed-repo-template.sh +# - scripts/compliance-remediate.sh # - test/scripts/lib/gitignore-baseline.bats # - test/scripts/sync-gitignore-baseline/** +# - test/scripts/compliance-remediate/** # - tests/test_seed_repo_template_gitignore.bats # # Gates (all must pass before merge): -# 1. shellcheck — static analysis of the lib + sync script + seed script -# 2. bats — unit tests for baseline extraction/upsert, sync, and seed behaviors +# 1. shellcheck — static analysis of the lib + sync + seed + remediate scripts +# 2. bats — unit tests for baseline extraction/upsert, sync, seed, and +# the compliance-remediate gitignore-baseline hook name: Gitignore Baseline Tests @@ -20,8 +23,10 @@ on: - 'scripts/lib/gitignore-baseline.sh' - 'scripts/sync-gitignore-baseline.sh' - 'scripts/seed-repo-template.sh' + - 'scripts/compliance-remediate.sh' - 'test/scripts/lib/gitignore-baseline.bats' - 'test/scripts/sync-gitignore-baseline/**' + - 'test/scripts/compliance-remediate/**' - 'tests/test_seed_repo_template_gitignore.bats' - '.github/workflows/gitignore-baseline-tests.yml' push: @@ -31,8 +36,10 @@ on: - 'scripts/lib/gitignore-baseline.sh' - 'scripts/sync-gitignore-baseline.sh' - 'scripts/seed-repo-template.sh' + - 'scripts/compliance-remediate.sh' - 'test/scripts/lib/gitignore-baseline.bats' - 'test/scripts/sync-gitignore-baseline/**' + - 'test/scripts/compliance-remediate/**' - 'tests/test_seed_repo_template_gitignore.bats' - '.github/workflows/gitignore-baseline-tests.yml' @@ -66,11 +73,13 @@ jobs: shellcheck --severity=warning -x \ scripts/lib/gitignore-baseline.sh \ scripts/sync-gitignore-baseline.sh \ - scripts/seed-repo-template.sh + scripts/seed-repo-template.sh \ + scripts/compliance-remediate.sh - name: Run bats suite run: | bats --print-output-on-failure \ test/scripts/lib/gitignore-baseline.bats \ test/scripts/sync-gitignore-baseline/ \ + test/scripts/compliance-remediate/ \ tests/test_seed_repo_template_gitignore.bats diff --git a/scripts/compliance-remediate.sh b/scripts/compliance-remediate.sh index 70cc0b8a0..af0e8e402 100644 --- a/scripts/compliance-remediate.sh +++ b/scripts/compliance-remediate.sh @@ -31,11 +31,21 @@ if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then exit 1 fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/gitignore-baseline.sh +source "$SCRIPT_DIR/lib/gitignore-baseline.sh" +# shellcheck source=scripts/lib/standards-deploy.sh +source "$SCRIPT_DIR/lib/standards-deploy.sh" + ORG="petry-projects" FINDINGS_FILE="${FINDINGS_FILE:-}" REPORT_DIR="${REPORT_DIR:-$(mktemp -d)}" DRY_RUN="${DRY_RUN:-false}" +# Canonical source of the marker-wrapped org secrets baseline (L1). Overridable +# as a test seam, matching scripts/sync-gitignore-baseline.sh. +GITIGNORE_CANONICAL="${GITIGNORE_CANONICAL:-$(cd "$SCRIPT_DIR/.." && pwd)/.gitignore}" + REMEDIATION_REPORT="$REPORT_DIR/remediation-report.md" SKIPPED_REPORT="$REPORT_DIR/skipped.md" @@ -629,6 +639,110 @@ Ref: \`standards/ci-standards.md#action-pinning-policy\` fi } +# --------------------------------------------------------------------------- +# PR-based: place or refresh the org secrets baseline (L1) in .gitignore +# +# Uses the shared, idempotent upsert_gitignore_baseline() (scripts/lib/gitignore- +# baseline.sh) so a repo's per-repo L2 — every line below the END marker — is +# never touched. Ships the fix via sd_deploy_via_pr(): a PR, never a direct +# default-branch push (rejected by ruleset-protected repos). Idempotency is +# keyed on the `gitignore-baseline` label and shared with sync-gitignore-baseline.sh, +# so an already-open baseline PR (from either tool) is reused, not duplicated. +# --------------------------------------------------------------------------- +remediate_gitignore_baseline() { + local repo="$1" check="$2" + + info "Remediating .gitignore secrets baseline for $ORG/$repo (finding: $check) ..." + + # Extract the canonical marker-wrapped L1 block from the org /.gitignore. A + # missing/half-open block is a hard error — never ship a partial baseline. + local block_file + block_file="$(mktemp)" + if ! gib_extract_baseline_block "$GITIGNORE_CANONICAL" > "$block_file" 2>/dev/null; then + rm -f "$block_file" + err "Canonical baseline block unreadable at $GITIGNORE_CANONICAL" + report_fail "$repo" "$check" "canonical secrets-baseline block unreadable at $GITIGNORE_CANONICAL" + return 0 + fi + + # Fetch the repo's current .gitignore (empty file if absent / 404). + local existing_file + existing_file="$(mktemp)" + gh api "repos/$ORG/$repo/contents/.gitignore" 2>/dev/null \ + | jq -r '.content // empty' 2>/dev/null \ + | b64_decode > "$existing_file" 2>/dev/null || true + + # Upsert: place/refresh the L1 block on top, preserving L2 below END verbatim. + # Compare as command-substituted strings (trailing-newline tolerant), matching + # sync-gitignore-baseline.sh's sync_repo(). + local existing upserted + existing="$(cat "$existing_file")" + if ! upserted="$(upsert_gitignore_baseline "$block_file" "$existing_file" 2>/dev/null)"; then + rm -f "$block_file" "$existing_file" + err "upsert_gitignore_baseline failed for $ORG/$repo" + report_fail "$repo" "$check" "upsert failed (half-open markers in the repo's .gitignore?)" + return 0 + fi + + # Idempotency: identical content means the repo already carries the baseline. + if [ "$existing" = "$upserted" ]; then + rm -f "$block_file" "$existing_file" + info "$ORG/$repo already carries the current baseline — no-op" + report_skip "$repo" "$check" "\`.gitignore\` already carries the current org secrets-baseline block (no-op)" + return 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + rm -f "$block_file" "$existing_file" + skip "[DRY] Would open .gitignore baseline PR in $ORG/$repo" + report_pr "$repo" "$check" "DRY: would open PR upserting the org secrets baseline into \`.gitignore\`" + return 0 + fi + + # Materialize the upserted content for the PR primitive. + local upserted_file + upserted_file="$(mktemp)" + printf '%s\n' "$upserted" > "$upserted_file" + rm -f "$block_file" "$existing_file" + + local branch title body outcome + branch="gitignore-baseline/$(date -u +%Y%m%d)" + title="fix: sync .gitignore org secrets baseline (compliance remediation)" + body="## Summary + +Upserts the marker-wrapped **org secrets baseline (L1)** into \`.gitignore\`, preserving this repo's per-repo L2 entries below the END marker. + +**Finding resolved:** \`$check\` + +## Why + +The baseline is the first layer of the push-protection program — it keeps credential files (\`.env\`, cloud credentials, \`*.tfvars\`, kubeconfigs, key material, …) out of the working tree before any scanning layer runs. It must be present and byte-for-byte identical across every repo. + +Ref: \`standards/gitignore-standard.md\` + +--- +*Auto-generated by \`scripts/compliance-remediate.sh\` to resolve \`gitignore_baseline\`.*" + + outcome="$(sd_deploy_via_pr "$ORG/$repo" ".gitignore" "$upserted_file" \ + "$branch" "gitignore-baseline" "$title" "$body")" || true + rm -f "$upserted_file" + + case "$outcome" in + "OPENED "*) + ok "Opened .gitignore baseline PR in $ORG/$repo: ${outcome#OPENED }" + report_pr "$repo" "$check" "${outcome#OPENED }" ;; + "SKIP_PR_OPEN "*) + skip "$ORG/$repo — baseline PR #${outcome#SKIP_PR_OPEN } already open" + report_skip "$repo" "$check" "a \`.gitignore\` baseline PR (#${outcome#SKIP_PR_OPEN }) is already open" ;; + "FAILED "*) + err "$ORG/$repo — ${outcome#FAILED }" + report_fail "$repo" "$check" "sd_deploy_via_pr: ${outcome#FAILED }" ;; + *) + err "$ORG/$repo — unexpected deploy outcome: ${outcome:-}" + report_fail "$repo" "$check" "unexpected sd_deploy_via_pr outcome: ${outcome:-}" ;; + esac +} + # --------------------------------------------------------------------------- # Dispatch a single finding to the correct remediation handler # --------------------------------------------------------------------------- @@ -734,8 +848,7 @@ remediate_finding() { "Adding the \`secret-scan\` gitleaks job to \`.github/workflows/ci.yml\` requires \`workflow\` token scope — copy the CI secret-scan job from standards/push-protection.md using a PAT" ;; push-protection/gitignore_baseline) - report_skip "$repo" "$check" \ - "\`.gitignore\` secrets-baseline block is missing or drifted — copy the \`BEGIN … END petry-projects secrets baseline\` block verbatim from the org \`/.gitignore\` into the repo's root \`.gitignore\` (do not edit inside the markers; L2 entries stay below END)" + remediate_gitignore_baseline "$repo" "$check" ;; push-protection/*) report_skip "$repo" "$check" \ diff --git a/test/scripts/compliance-remediate/gitignore-baseline.bats b/test/scripts/compliance-remediate/gitignore-baseline.bats new file mode 100644 index 000000000..110f3e1f8 --- /dev/null +++ b/test/scripts/compliance-remediate/gitignore-baseline.bats @@ -0,0 +1,235 @@ +#!/usr/bin/env bats +# Tests for the push-protection/gitignore_baseline remediation path in +# scripts/compliance-remediate.sh (STORY4 / #800). +# +# Before this change the finding was routed to `report_skip` — a human was told +# to copy the block by hand. It is now auto-remediated: the shared, idempotent +# upsert_gitignore_baseline() (scripts/lib/gitignore-baseline.sh) places or +# refreshes the marker-wrapped L1 block in the target repo's .gitignore and the +# change ships as a PR via sd_deploy_via_pr(), preserving the repo's L2 (every +# line below the END marker) verbatim. +# +# The gh stub scripts the full fetch → branch → PUT → PR flow, so tests assert +# both the remediation-report/skipped outcome and the concrete gh call sequence. + +bats_require_minimum_version 1.5.0 + +load 'helpers/setup' + +# --------------------------------------------------------------------------- +# Fake gh installed on PATH. Behaviour is env-driven: +# GH_GITIGNORE_B64 — base64 of the target repo's current .gitignore. Unset → +# the contents fetch 404s (the repo has no .gitignore). +# GH_FILE_SHA — blob sha returned for the file on the sync branch. +# GH_EXISTING_PR — a pre-existing open PR number (idempotency). +# GH_PR_CREATE_RC — non-zero → `gh pr create` fails. +# GH_PR_URL — url printed by `gh pr create`. +# All invocations are appended to $GH_CALLS. +# --------------------------------------------------------------------------- +install_gh_stub() { + local bin="${TT_TMP}/bin" + mkdir -p "$bin" + cat > "$bin/gh" <<'STUB' +#!/usr/bin/env bash +{ printf 'gh'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >> "$GH_CALLS" +sub="${1:-}"; shift || true +args="$*" +case "$sub" in + pr) + case "${1:-}" in + list) printf '%s' "${GH_EXISTING_PR:-}" ;; + create) + [ "${GH_PR_CREATE_RC:-0}" -ne 0 ] && exit "${GH_PR_CREATE_RC}" + printf '%s\n' "${GH_PR_URL:-https://github.com/petry-projects/markets/pull/7}" ;; + esac + ;; + label) exit 0 ;; + api) + endpoint="${1:-}" + case "$endpoint" in + */git/refs) exit "${GH_REF_CREATE_RC:-0}" ;; + */git/ref/heads/*) + if printf '%s' "$args" | grep -q 'object.sha'; then + printf '%s' "${GH_BASE_SHA-basesha111}" + else + exit "${GH_REF_EXISTS_RC:-0}" + fi ;; + *contents/.gitignore"?"ref=*) printf '%s' "${GH_FILE_SHA:-}" ;; + *contents/.gitignore) + # PUT of the upserted file onto the sync branch. + if printf '%s' "$args" | grep -q -- '--method PUT'; then + exit "${GH_PUT_RC:-0}" + fi + # Plain GET of the target repo's current .gitignore. + if [ -n "${GH_GITIGNORE_B64:-}" ]; then + printf '{"sha":"existingsha","content":"%s"}' "$GH_GITIGNORE_B64" + else + exit 1 # 404 — .gitignore absent + fi ;; + repos/*) printf '%s' "${GH_DEFAULT_BRANCH:-main}" ;; + esac + ;; +esac +exit 0 +STUB + chmod +x "$bin/gh" + PATH="${bin}:${PATH}" + export PATH +} + +b64() { base64 -w 0 2>/dev/null || base64 -b 0; } + +setup() { + tt_make_tmpdir + GH_CALLS="${TT_TMP}/gh-calls.log"; export GH_CALLS + : > "$GH_CALLS" + install_gh_stub + CANONICAL="${TT_REPO_ROOT}/.gitignore" +} + +teardown() { + tt_cleanup_tmpdir +} + +run_remediate() { + GH_TOKEN=fake \ + FINDINGS_FILE="$1" \ + REPORT_DIR="${TT_TMP}/report" \ + DRY_RUN="${2:-false}" \ + run bash "$TT_SCRIPT" +} + +# --------------------------------------------------------------------------- +# INSERT — the repo has no .gitignore at all → open a PR seeding the baseline. +# --------------------------------------------------------------------------- +@test "opens a PR to INSERT the baseline when the repo has no .gitignore" { + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -eq 0 ] + + # Lands on the remediation (PR) table with the PR url, never in skipped. + grep -q 'gitignore_baseline' "${TT_TMP}/report/remediation-report.md" + grep -q 'pull/7' "${TT_TMP}/report/remediation-report.md" + ! grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + + # Full PR flow ran against the target repo. + grep -q 'gh api repos/petry-projects/markets/contents/.gitignore --method PUT' "$GH_CALLS" + grep -q 'gh pr create --repo petry-projects/markets' "$GH_CALLS" +} + +# --------------------------------------------------------------------------- +# REFRESH — a drifted marker-wrapped block is replaced in place; the repo's L2 +# (everything below END) is preserved verbatim in the PUT payload. +# --------------------------------------------------------------------------- +@test "opens a PR to REFRESH a drifted block and preserves L2 below END" { + printf '%s\n' \ + '# >>> BEGIN petry-projects secrets baseline (managed by .github — do not edit) >>>' \ + '.env' \ + 'STALE-DRIFTED-ENTRY' \ + '# <<< END petry-projects secrets baseline <<<' \ + 'node_modules/' \ + 'dist/' > "${TT_TMP}/existing" + GH_GITIGNORE_B64="$(b64 < "${TT_TMP}/existing")"; export GH_GITIGNORE_B64 + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -eq 0 ] + grep -q 'gitignore_baseline' "${TT_TMP}/report/remediation-report.md" + ! grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + + # Decode the content PUT to the branch and confirm the block was refreshed to + # the canonical L1 while the repo's L2 survived and the drift is gone. + put_b64="$(grep 'method PUT' "$GH_CALLS" | grep -oE 'content=[A-Za-z0-9+/=]+' | head -1 | cut -d= -f2-)" + [ -n "$put_b64" ] + decoded="$(printf '%s' "$put_b64" | base64 -d)" + printf '%s\n' "$decoded" | grep -qxF 'node_modules/' + printf '%s\n' "$decoded" | grep -qxF 'dist/' + printf '%s\n' "$decoded" | grep -qxF '# <<< END petry-projects secrets baseline <<<' + ! printf '%s\n' "$decoded" | grep -qxF 'STALE-DRIFTED-ENTRY' +} + +# --------------------------------------------------------------------------- +# INSERT (marker-less) — a repo with a hand-rolled .gitignore keeps every line +# as L2 below the freshly-inserted block. +# --------------------------------------------------------------------------- +@test "marker-less .gitignore is preserved wholesale as L2 below the block" { + printf '%s\n' 'node_modules/' '.DS_Store' > "${TT_TMP}/existing" + GH_GITIGNORE_B64="$(b64 < "${TT_TMP}/existing")"; export GH_GITIGNORE_B64 + + findings="$(tt_write_finding "broodly" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -eq 0 ] + grep -q 'gitignore_baseline' "${TT_TMP}/report/remediation-report.md" + + put_b64="$(grep 'method PUT' "$GH_CALLS" | grep -oE 'content=[A-Za-z0-9+/=]+' | head -1 | cut -d= -f2-)" + decoded="$(printf '%s' "$put_b64" | base64 -d)" + printf '%s\n' "$decoded" | grep -qxF 'node_modules/' + printf '%s\n' "$decoded" | grep -qxF '.DS_Store' + printf '%s\n' "$decoded" | grep -qxF '# >>> BEGIN petry-projects secrets baseline (managed by .github — do not edit) >>>' +} + +# --------------------------------------------------------------------------- +# Idempotency — a repo already carrying the current baseline is a no-op: no PR, +# no mutating call, and it is recorded as a skip (not a failure). +# --------------------------------------------------------------------------- +@test "already-current baseline is an idempotent no-op (no PR opened)" { + GH_GITIGNORE_B64="$(b64 < "$CANONICAL")"; export GH_GITIGNORE_B64 + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -eq 0 ] + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + ! grep -q 'gitignore_baseline' "${TT_TMP}/report/remediation-report.md" + + ! grep -q 'git/refs --method POST' "$GH_CALLS" + ! grep -q 'method PUT' "$GH_CALLS" + ! grep -q 'gh pr create' "$GH_CALLS" +} + +# --------------------------------------------------------------------------- +# Dry-run — plan the PR, record it on the remediation report, make no mutation. +# --------------------------------------------------------------------------- +@test "dry-run records a planned PR and issues no mutating gh calls" { + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" true + + [ "$status" -eq 0 ] + grep -q 'gitignore_baseline' "${TT_TMP}/report/remediation-report.md" + grep -qi 'DRY' "${TT_TMP}/report/remediation-report.md" + + ! grep -q 'git/refs --method POST' "$GH_CALLS" + ! grep -q 'method PUT' "$GH_CALLS" + ! grep -q 'gh pr create' "$GH_CALLS" +} + +# --------------------------------------------------------------------------- +# Failure — a failed PR creation is reported as a failure (exit non-zero). +# --------------------------------------------------------------------------- +@test "failed PR creation is reported as a failure" { + GH_PR_CREATE_RC=1; export GH_PR_CREATE_RC + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -ne 0 ] + grep -q 'FAILED' "${TT_TMP}/report/skipped.md" + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" +} + +# --------------------------------------------------------------------------- +# Idempotency — an already-open baseline PR is reused, not duplicated. +# --------------------------------------------------------------------------- +@test "an already-open baseline PR is reused (skip), not duplicated" { + GH_EXISTING_PR="99"; export GH_EXISTING_PR + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + [ "$status" -eq 0 ] + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + ! grep -q 'gh pr create' "$GH_CALLS" +} diff --git a/test/scripts/compliance-remediate/routing-messages.bats b/test/scripts/compliance-remediate/routing-messages.bats index 57887929c..f8aa1de37 100644 --- a/test/scripts/compliance-remediate/routing-messages.bats +++ b/test/scripts/compliance-remediate/routing-messages.bats @@ -61,31 +61,10 @@ teardown() { [ ! -s "$GH_STUB_LOG" ] } -# --------------------------------------------------------------------------- -# push-protection/gitignore_baseline -# --------------------------------------------------------------------------- - -@test "gitignore_baseline skips with .gitignore baseline-block guidance" { - findings="$(tt_write_finding "broodminder-export" "push-protection" "gitignore_baseline")" - report_dir="${TT_TMP}/report" - - GH_TOKEN=fake \ - FINDINGS_FILE="$findings" \ - REPORT_DIR="$report_dir" \ - DRY_RUN=false \ - run bash "$TT_SCRIPT" - - [ "$status" -eq 0 ] - - grep -q 'gitignore_baseline' "$report_dir/skipped.md" - ! grep -q 'gitignore_baseline' "$report_dir/remediation-report.md" - - # Accurate reason: copy the org secrets-baseline block verbatim. - grep -q 'secrets-baseline block' "$report_dir/skipped.md" - - # The misleading apply-repo-settings.sh pointer must be gone for this check. - ! grep -q 'apply-repo-settings.sh' "$report_dir/skipped.md" -} +# NOTE: push-protection/gitignore_baseline is no longer a skip — STORY4 (#800) +# promoted it to an auto-remediation (upsert the org secrets baseline, ship a +# PR). Its coverage lives in gitignore-baseline.bats, which stubs the full PR +# flow. It is intentionally absent here. # --------------------------------------------------------------------------- # standards/missing-copilot-instructions From e97ffb245a1f717f844f34a48213cc089b74f139 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:10:49 +0000 Subject: [PATCH 2/2] chore: dev-lead update (review-changes) [skip ci-relay] --- scripts/compliance-remediate.sh | 24 ++++++-- .../gitignore-baseline.bats | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/scripts/compliance-remediate.sh b/scripts/compliance-remediate.sh index af0e8e402..f14e5cc31 100644 --- a/scripts/compliance-remediate.sh +++ b/scripts/compliance-remediate.sh @@ -665,12 +665,26 @@ remediate_gitignore_baseline() { return 0 fi - # Fetch the repo's current .gitignore (empty file if absent / 404). - local existing_file + # Fetch the repo's current .gitignore. A genuine 404 (file absent) is + # fine — continue with an empty file. Any other failure (auth, rate-limit, + # 5xx) means we cannot know what L2 content the file holds; fail fast rather + # than silently replacing it with a baseline-only file. + local existing_file api_out api_err existing_file="$(mktemp)" - gh api "repos/$ORG/$repo/contents/.gitignore" 2>/dev/null \ - | jq -r '.content // empty' 2>/dev/null \ - | b64_decode > "$existing_file" 2>/dev/null || true + api_out="$(mktemp)" + api_err="$(mktemp)" + if gh api "repos/$ORG/$repo/contents/.gitignore" > "$api_out" 2>"$api_err"; then + jq -r '.content // empty' "$api_out" 2>/dev/null | b64_decode > "$existing_file" 2>/dev/null || true + rm -f "$api_out" "$api_err" + elif grep -qiF 'HTTP 404' "$api_err" 2>/dev/null || grep -qiF 'Not Found' "$api_err" 2>/dev/null; then + rm -f "$api_out" "$api_err" + else + local api_err_msg; api_err_msg="$(cat "$api_err")" + rm -f "$block_file" "$existing_file" "$api_out" "$api_err" + err "gh api failed fetching .gitignore for $ORG/$repo: $api_err_msg" + report_fail "$repo" "$check" "gh api error fetching .gitignore (auth/rate-limit/server error)" + return 0 + fi # Upsert: place/refresh the L1 block on top, preserving L2 below END verbatim. # Compare as command-substituted strings (trailing-newline tolerant), matching diff --git a/test/scripts/compliance-remediate/gitignore-baseline.bats b/test/scripts/compliance-remediate/gitignore-baseline.bats index 110f3e1f8..679b29b9c 100644 --- a/test/scripts/compliance-remediate/gitignore-baseline.bats +++ b/test/scripts/compliance-remediate/gitignore-baseline.bats @@ -60,10 +60,16 @@ case "$sub" in if printf '%s' "$args" | grep -q -- '--method PUT'; then exit "${GH_PUT_RC:-0}" fi + # Configurable non-404 API error (auth/rate-limit/server-error tests). + if [ -n "${GH_GITIGNORE_API_RC:-}" ]; then + printf 'gh: HTTP 429: API rate limit exceeded\n' >&2 + exit "${GH_GITIGNORE_API_RC}" + fi # Plain GET of the target repo's current .gitignore. if [ -n "${GH_GITIGNORE_B64:-}" ]; then printf '{"sha":"existingsha","content":"%s"}' "$GH_GITIGNORE_B64" else + printf 'gh: Not Found (HTTP 404)\n' >&2 exit 1 # 404 — .gitignore absent fi ;; repos/*) printf '%s' "${GH_DEFAULT_BRANCH:-main}" ;; @@ -233,3 +239,52 @@ run_remediate() { grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" ! grep -q 'gh pr create' "$GH_CALLS" } + +# --------------------------------------------------------------------------- +# Failure — incomplete canonical block (missing END marker) is a hard error. +# --------------------------------------------------------------------------- +@test "incomplete canonical baseline block is reported as a failure" { + GITIGNORE_CANONICAL="${TT_TMP}/broken-canonical"; export GITIGNORE_CANONICAL + printf '%s\n' \ + '# >>> BEGIN petry-projects secrets baseline (managed by .github — do not edit) >>>' \ + '.env' > "$GITIGNORE_CANONICAL" # missing END marker + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + grep -q 'FAILED' "${TT_TMP}/report/skipped.md" + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + ! grep -q 'gh pr create' "$GH_CALLS" +} + +# --------------------------------------------------------------------------- +# Failure — half-open existing .gitignore (one marker) is refused by upsert. +# --------------------------------------------------------------------------- +@test "half-open existing .gitignore (one marker) is reported as a failure" { + printf '%s\n' \ + '# >>> BEGIN petry-projects secrets baseline (managed by .github — do not edit) >>>' \ + '.env' \ + 'node_modules/' > "${TT_TMP}/half-open" # missing END marker + GH_GITIGNORE_B64="$(b64 < "${TT_TMP}/half-open")"; export GH_GITIGNORE_B64 + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + grep -q 'FAILED' "${TT_TMP}/report/skipped.md" + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + ! grep -q 'gh pr create' "$GH_CALLS" +} + +# --------------------------------------------------------------------------- +# Failure — a non-404 gh api error (auth/rate-limit/5xx) fails fast. +# --------------------------------------------------------------------------- +@test "non-404 gh api error fetching .gitignore is reported as a failure" { + GH_GITIGNORE_API_RC=1; export GH_GITIGNORE_API_RC + + findings="$(tt_write_finding "markets" "push-protection" "gitignore_baseline")" + run_remediate "$findings" + + grep -q 'FAILED' "${TT_TMP}/report/skipped.md" + grep -q 'gitignore_baseline' "${TT_TMP}/report/skipped.md" + ! grep -q 'gh pr create' "$GH_CALLS" +}