From b5cccf865bff7193bfe682e7c0baf2966bbd34ab Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 14:33:21 +0000 Subject: [PATCH] chore: add apply-repo-settings.sh and disable CodeRabbit check-suite auto-trigger Applied check-suite auto-trigger fix via GitHub API (both Claude app_id 1236702 and CodeRabbit app_id 347564 now have auto_trigger_checks: false), resolving the compliance finding that was permanently blocking auto-merge. Also adds scripts/apply-repo-settings.sh and scripts/lib/push-protection.sh (synced from petry-projects/.github) so the settings can be re-applied or audited from this repo in the future. Closes #258 Co-authored-by: Don Petry --- scripts/apply-repo-settings.sh | 346 +++++++++++++++++++++++++++++++++ scripts/lib/push-protection.sh | 310 +++++++++++++++++++++++++++++ 2 files changed, 656 insertions(+) create mode 100644 scripts/apply-repo-settings.sh create mode 100644 scripts/lib/push-protection.sh diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh new file mode 100644 index 00000000..fa49f71f --- /dev/null +++ b/scripts/apply-repo-settings.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# apply-repo-settings.sh — Apply standard repository settings to petry-projects repos +# +# Companion script to compliance-audit.sh. Applies the settings defined in: +# standards/github-settings.md#repository-settings--standard-defaults +# standards/push-protection.md#required-repo-level-settings +# +# Usage: +# # Apply to a specific repo: +# GH_TOKEN= ./scripts/apply-repo-settings.sh +# +# # Apply to all non-archived org repos: +# GH_TOKEN= ./scripts/apply-repo-settings.sh --all +# +# # Dry run (show what would be changed): +# DRY_RUN=true GH_TOKEN= ./scripts/apply-repo-settings.sh +# +# Requirements: +# - Bash 4+ (uses associative arrays — macOS ships Bash 3.2; use GitHub Actions or brew install bash) +# - GH_TOKEN must have admin:repo scope (or be an admin of the org) +# - gh CLI must be installed + +set -euo pipefail + +if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + echo "[ERROR] Bash 4+ required (associative arrays). Found: $BASH_VERSION" >&2 + echo " On macOS: brew install bash, then run with /opt/homebrew/bin/bash" >&2 + exit 1 +fi + +ORG="petry-projects" +DRY_RUN="${DRY_RUN:-false}" + +info() { echo "[INFO] $*"; } +ok() { echo "[OK] $*"; } +warn() { echo "[WARN] $*" >&2; } +err() { echo "[ERROR] $*" >&2; } +skip() { echo "[SKIP] $*"; } + +# App IDs whose auto_trigger_checks must be disabled — these apps create +# orphaned "queued" suites on every push that are never completed, permanently +# blocking GitHub auto-merge. +# 1236702 = Claude (anthropics/claude-code-action) +# 347564 = CodeRabbit +CHECK_SUITE_APP_IDS=(1236702 347564) + +# Source the shared push-protection library — provides +# pp_apply_security_and_analysis() and the PP_REQUIRED_SA_SETTINGS list. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/push-protection.sh +. "$SCRIPT_DIR/lib/push-protection.sh" + +usage() { + echo "Usage: $0 " + echo " $0 --all" + echo "" + echo "Environment variables:" + echo " GH_TOKEN GitHub token with admin:repo scope (required)" + echo " DRY_RUN Set to 'true' to preview changes without applying (default: false)" + exit 1 +} + +apply_labels() { + local repo="$1" + info "Applying standard labels to $ORG/$repo ..." + + # Format: "name|color|description" — matches standards/github-settings.md#labels--standard-set + local label_configs=( + "security|d93f0b|Security-related PRs and issues" + "dependencies|0075ca|Dependency update PRs" + "scorecard|d93f0b|OpenSSF Scorecard findings" + "bug|d73a4a|Bug reports" + "enhancement|a2eeef|Feature requests" + "documentation|0075ca|Documentation changes" + "in-progress|fbca04|An agent is actively working this issue" + ) + + for config in "${label_configs[@]}"; do + IFS='|' read -r name color description <<< "$config" + if [ "$DRY_RUN" = "true" ]; then + skip "DRY_RUN=true — would create/update label '$name' (#$color) in $ORG/$repo" + else + gh label create "$name" \ + --repo "$ORG/$repo" \ + --description "$description" \ + --color "$color" \ + --force 2>/dev/null && ok " label '$name' applied" || err " failed to apply label '$name'" + fi + done +} + +apply_settings() { + local repo="$1" + local repo_json="$2" + info "Applying standard settings to $ORG/$repo ..." + + # Extract current settings from the pre-fetched repo JSON + local current + current=$(echo "$repo_json" | jq '{ + allow_auto_merge: .allow_auto_merge, + delete_branch_on_merge: .delete_branch_on_merge, + allow_squash_merge: .allow_squash_merge, + allow_merge_commit: .allow_merge_commit, + allow_rebase_merge: .allow_rebase_merge, + has_discussions: .has_discussions, + has_issues: .has_issues, + squash_merge_commit_title: .squash_merge_commit_title, + squash_merge_commit_message: .squash_merge_commit_message + }' 2>/dev/null || echo "{}") + + if [ "$current" = "{}" ] || [ "$current" = "null" ]; then + err "Could not parse settings for $ORG/$repo" + return 1 + fi + + # Standard settings from standards/github-settings.md#repository-settings--standard-defaults + declare -A EXPECTED=( + [allow_auto_merge]="true" + [delete_branch_on_merge]="true" + [allow_squash_merge]="true" + [allow_merge_commit]="true" + [allow_rebase_merge]="true" + [has_discussions]="true" + [has_issues]="true" + ) + + local needs_patch=false + local patch_args=() + + for key in "${!EXPECTED[@]}"; do + local actual + actual=$(echo "$current" | jq -r ".$key // \"null\"") + local expected="${EXPECTED[$key]}" + + if [ "$actual" != "$expected" ]; then + info " $key: $actual → $expected" + needs_patch=true + patch_args+=(-F "$key=$expected") + else + ok " $key: already $actual" + fi + done + + # Check string settings separately (jq -f flag for strings) + local squash_title + squash_title=$(echo "$current" | jq -r '.squash_merge_commit_title // "null"') + if [ "$squash_title" != "PR_TITLE" ]; then + info " squash_merge_commit_title: $squash_title → PR_TITLE" + needs_patch=true + patch_args+=(-f squash_merge_commit_title=PR_TITLE) + else + ok " squash_merge_commit_title: already PR_TITLE" + fi + + local squash_msg + squash_msg=$(echo "$current" | jq -r '.squash_merge_commit_message // "null"') + if [ "$squash_msg" != "COMMIT_MESSAGES" ]; then + info " squash_merge_commit_message: $squash_msg → COMMIT_MESSAGES" + needs_patch=true + patch_args+=(-f squash_merge_commit_message=COMMIT_MESSAGES) + else + ok " squash_merge_commit_message: already COMMIT_MESSAGES" + fi + + if [ "$needs_patch" = false ]; then + ok "$ORG/$repo is already fully compliant — no changes needed" + return 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + skip "DRY_RUN=true — skipping PATCH for $ORG/$repo" + return 0 + fi + + gh api -X PATCH "repos/$ORG/$repo" "${patch_args[@]}" > /dev/null + ok "$ORG/$repo settings updated successfully" +} + +# --------------------------------------------------------------------------- +# apply_codeql_default_setup — enable GitHub-managed CodeQL default setup +# +# Per standards/ci-standards.md#2-codeql-analysis-github-managed-default-setup, +# CodeQL is configured via the code-scanning/default-setup endpoint, not a +# per-repo workflow file. Languages are auto-detected from the default branch. +# +# Idempotent: if state is already "configured", we no-op. If "not-configured", +# we PATCH to enable. Repos listed in CODEQL_ADVANCED_EXCEPTIONS are skipped +# (they are approved to keep an inline codeql.yml; see the escape hatch in +# ci-standards.md §2). The API rejects updates on repos without code scanning +# capability (e.g. private repos without GHAS); we log a warning and continue +# so that --all runs are not blocked by a single unsupported repo. +# --------------------------------------------------------------------------- + +# Repos approved for advanced CodeQL setup (inline codeql.yml). +# Each entry must have a corresponding standards PR documenting the exception. +CODEQL_ADVANCED_EXCEPTIONS=() + +apply_codeql_default_setup() { + local repo="$1" + info "Configuring CodeQL default setup for $ORG/$repo ..." + + # Skip repos approved for advanced (inline workflow) CodeQL setup. + for exception in "${CODEQL_ADVANCED_EXCEPTIONS[@]}"; do + if [ "$repo" = "$exception" ]; then + skip " $repo is in CODEQL_ADVANCED_EXCEPTIONS — skipping default setup" + return 0 + fi + done + + local current_state + current_state=$(gh api "repos/$ORG/$repo/code-scanning/default-setup" --jq '.state' 2>/dev/null || echo "") + + if [ "$current_state" = "configured" ]; then + ok " CodeQL default setup already configured" + return 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + skip "DRY_RUN=true — would enable CodeQL default setup (current state: ${current_state:-unknown})" + return 0 + fi + + local api_err + if api_err=$(gh api -X PATCH "repos/$ORG/$repo/code-scanning/default-setup" \ + -F state=configured \ + -F query_suite=default 2>&1); then + ok " CodeQL default setup enabled" + else + # Non-fatal: log warning and continue so --all runs are not blocked by + # repos that lack code scanning capability (private without GHAS, + # archived, or empty default branch). + warn " Failed to enable CodeQL default setup for $repo — manual review required. API response: $api_err" + return 0 + fi +} + +# --------------------------------------------------------------------------- +# apply_check_suite_prefs — disable auto-trigger check suites for Claude and +# CodeRabbit. Without this, GitHub auto-creates "queued" suites on every push +# that are never completed, permanently blocking auto-merge. +# --------------------------------------------------------------------------- +apply_check_suite_prefs() { + local repo="$1" + info "Configuring check-suite auto-trigger preferences for $ORG/$repo ..." + + local prefs + if ! prefs=$(gh api "repos/$ORG/$repo/check-suites/preferences" 2>&1); then + err " Could not read check-suite preferences for $repo. API response: $prefs" + return 1 + fi + + local all_disabled=true + for app_id in "${CHECK_SUITE_APP_IDS[@]}"; do + local setting + setting=$(echo "$prefs" | jq -r --argjson id "$app_id" \ + '.preferences.auto_trigger_checks // [] | map(select(.app_id == $id)) | first | .setting // "missing"') + # "missing" means the app has never run in this repo — no orphaned suite possible, skip + if [ "$setting" != "false" ] && [ "$setting" != "missing" ]; then + all_disabled=false + fi + done + + if [ "$all_disabled" = true ]; then + ok "$ORG/$repo check-suite prefs already correct" + return 0 + fi + + if [ "${DRY_RUN:-false}" = "true" ]; then + skip "DRY_RUN — skipping check-suite prefs PATCH for $repo" + return 0 + fi + + local payload + payload=$(printf '%s\n' "${CHECK_SUITE_APP_IDS[@]}" | \ + jq -Rs 'split("\n") | map(select(. != "")) | map(tonumber) | + {"auto_trigger_checks": map({"app_id": ., "setting": false})}') + + local api_err + if api_err=$(gh api -X PATCH "repos/$ORG/$repo/check-suites/preferences" \ + --input - <<< "$payload" 2>&1 >/dev/null); then + ok " auto-trigger disabled for app_ids: ${CHECK_SUITE_APP_IDS[*]}" + else + err " PATCH failed for $repo. API response: $api_err" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if [ $# -eq 0 ]; then + usage +fi + +if [ -z "${GH_TOKEN:-}" ]; then + err "GH_TOKEN is required — provide a personal access token or GitHub App token with admin:repo scope" + exit 1 +fi + +export GH_TOKEN + +if [ "$1" = "--all" ]; then + info "Fetching all non-archived repos in $ORG ..." + repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) + + if [ -z "$repos" ]; then + err "No repositories found in $ORG — check GH_TOKEN permissions" + exit 1 + fi + + failed=0 + for repo in $repos; do + # Fetch full repo JSON once and share across functions + repo_json=$(gh api "repos/$ORG/$repo" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + err "Could not fetch settings for $ORG/$repo — check token permissions and repo name" + failed=$((failed + 1)) + continue + fi + + apply_settings "$repo" "$repo_json" || failed=$((failed + 1)) + apply_labels "$repo" + pp_apply_security_and_analysis "$repo" || failed=$((failed + 1)) + apply_codeql_default_setup "$repo" || failed=$((failed + 1)) + apply_check_suite_prefs "$repo" || failed=$((failed + 1)) + done + + if [ "$failed" -gt 0 ]; then + err "$failed repo(s) failed — check output above for details" + exit 1 + fi + + ok "All repos processed successfully" +else + repo_json=$(gh api "repos/$ORG/$1" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + err "Could not fetch settings for $ORG/$1 — check token permissions and repo name" + exit 1 + fi + + apply_settings "$1" "$repo_json" + apply_labels "$1" + pp_apply_security_and_analysis "$1" + apply_codeql_default_setup "$1" + apply_check_suite_prefs "$1" +fi diff --git a/scripts/lib/push-protection.sh b/scripts/lib/push-protection.sh new file mode 100644 index 00000000..9f1ea5da --- /dev/null +++ b/scripts/lib/push-protection.sh @@ -0,0 +1,310 @@ +# shellcheck shell=bash +# scripts/lib/push-protection.sh — Shared push-protection logic +# +# Sourceable Bash library that implements the apply + check functions for +# the petry-projects Push Protection Standard: +# +# standards/push-protection.md +# +# Both scripts/apply-repo-settings.sh and scripts/compliance-audit.sh source +# this file so the org-required `security_and_analysis` settings, audit +# checks, and remediation logic live in exactly one place. Any future change +# to the standard's required state happens here, not in two parallel copies. +# +# ---------------------------------------------------------------------------- +# Caller contract +# ---------------------------------------------------------------------------- +# This library is `set -euo pipefail`-safe and designed to be sourced by a +# parent script. It does NOT call `set` itself. +# +# Required by ALL functions: +# - $ORG — GitHub org slug (e.g. "petry-projects") +# +# Required by `pp_apply_*` functions (used by apply-repo-settings.sh): +# - info(), ok(), err(), skip() — log helpers (string arg) +# - $DRY_RUN — "true" / "false" +# - `gh` CLI on PATH, GH_TOKEN with admin:repo scope +# +# Required by `pp_check_*` functions (used by compliance-audit.sh): +# - gh_api() — wrapper around `gh api` with retry +# - add_finding() — add_finding [] +# - `gh` CLI on PATH, GH_TOKEN with read:org + repo scope +# +# Functions are namespaced with the `pp_` prefix to avoid colliding with +# caller helpers. + +# --------------------------------------------------------------------------- +# Required state — single source of truth +# --------------------------------------------------------------------------- +# Each entry: "api_key:expected_value:severity:human_detail" +# Severity is one of: error, warning. The audit and the apply script both +# read from this list, so adding a new flag here automatically extends both. +PP_REQUIRED_SA_SETTINGS=( + "secret_scanning:enabled:error:Secret scanning must be enabled" + "secret_scanning_push_protection:enabled:error:Secret scanning push protection must be enabled" + "secret_scanning_ai_detection:enabled:warning:Secret scanning AI detection should be enabled" + "secret_scanning_non_provider_patterns:enabled:warning:Secret scanning non-provider patterns should be enabled" + "dependabot_security_updates:enabled:warning:Dependabot security updates should be enabled" +) + +# Minimum entries that every repo's .gitignore MUST contain. Every repo +# starting from the org baseline at /.gitignore satisfies these by default. +PP_REQUIRED_GITIGNORE_PATTERNS=( + ".env" + "*.pem" + "*.key" +) + +# Number of days back the bypass-recency check looks for unjustified +# push-protection bypasses. +PP_BYPASS_LOOKBACK_DAYS="${PP_BYPASS_LOOKBACK_DAYS:-30}" + +# Standard reference path used by every check finding. +PP_STANDARD_REF="standards/push-protection.md" + +# --------------------------------------------------------------------------- +# Apply: security_and_analysis (used by apply-repo-settings.sh) +# --------------------------------------------------------------------------- +# Idempotent: fetches current state, only PATCHes when at least one flag +# differs from the required value. Honors $DRY_RUN. +pp_apply_security_and_analysis() { + local repo="$1" + info "Applying push-protection security_and_analysis to $ORG/$repo ..." + + local current + current=$(gh api "repos/$ORG/$repo" --jq '.security_and_analysis // {}' 2>/dev/null || echo "{}") + + if [ "$current" = "{}" ] || [ -z "$current" ]; then + err "Could not fetch security_and_analysis for $ORG/$repo — check token has admin scope" + return 1 + fi + + local needs_patch=false + local payload="{}" + + local entry key expected actual + for entry in "${PP_REQUIRED_SA_SETTINGS[@]}"; do + IFS=':' read -r key expected _ _ <<< "$entry" + actual=$(echo "$current" | jq -r ".\"$key\".status // \"null\"") + + if [ "$actual" != "$expected" ]; then + info " $key: $actual → $expected" + needs_patch=true + payload=$(echo "$payload" | jq --arg k "$key" --arg v "$expected" '. + {($k): {status: $v}}') + else + ok " $key: already $actual" + fi + done + + if [ "$needs_patch" = false ]; then + ok "$ORG/$repo security_and_analysis already fully compliant — no changes needed" + return 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + skip "DRY_RUN=true — skipping security_and_analysis PATCH for $ORG/$repo" + return 0 + fi + + # Wrap the per-key payload in a top-level security_and_analysis object and + # PATCH it via stdin (gh api -F doesn't accept nested JSON). + local full_payload + full_payload=$(echo "$payload" | jq '{security_and_analysis: .}') + + if echo "$full_payload" | gh api -X PATCH "repos/$ORG/$repo" --input - > /dev/null 2>&1; then + ok "$ORG/$repo security_and_analysis updated successfully" + else + err "Failed to PATCH security_and_analysis for $ORG/$repo — check admin scope and that the org plan supports these features" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Check: security_and_analysis (used by compliance-audit.sh) +# --------------------------------------------------------------------------- +# Reads the same PP_REQUIRED_SA_SETTINGS list the apply function uses, so +# the audit can never drift from what apply enforces. +pp_check_security_and_analysis() { + local repo="$1" + + local sa + sa=$(gh_api "repos/$ORG/$repo" --jq '.security_and_analysis // {}' 2>/dev/null || echo "{}") + + if [ "$sa" = "{}" ] || [ -z "$sa" ]; then + add_finding "$repo" "push-protection" "security_and_analysis_unavailable" "warning" \ + "Could not fetch security_and_analysis — token may lack admin scope, or the repo's plan does not expose these settings" \ + "$PP_STANDARD_REF#required-repo-level-settings" + return + fi + + local entry key expected severity detail actual + for entry in "${PP_REQUIRED_SA_SETTINGS[@]}"; do + IFS=':' read -r key expected severity detail <<< "$entry" + actual=$(echo "$sa" | jq -r ".\"$key\".status // \"null\"") + if [ "$actual" != "$expected" ]; then + add_finding "$repo" "push-protection" "$key" "$severity" \ + "$detail (current: \`$actual\`, expected: \`$expected\`)" \ + "$PP_STANDARD_REF#required-repo-level-settings" + fi + done +} + +# --------------------------------------------------------------------------- +# Check: open secret-scanning alerts +# --------------------------------------------------------------------------- +# Any open alert is a real leaked credential that needs rotation. This is an +# `error` finding — open alerts MUST be triaged within the SLA in the +# incident-response section of the standard. +pp_check_open_secret_alerts() { + local repo="$1" + + local alerts + alerts=$(gh_api "repos/$ORG/$repo/secret-scanning/alerts?state=open&per_page=100" 2>/dev/null || echo "[]") + + # 404 / disabled secret scanning produces a non-array response; coerce to [] + if ! echo "$alerts" | jq -e 'type == "array"' >/dev/null 2>&1; then + return + fi + + local count + count=$(echo "$alerts" | jq 'length') + + if [ "$count" -gt 0 ]; then + add_finding "$repo" "push-protection" "open_secret_alerts" "error" \ + "$count open secret-scanning alert(s) — rotate the leaked credentials before resolving" \ + "$PP_STANDARD_REF#incident-response" + fi +} + +# --------------------------------------------------------------------------- +# Check: ci.yml contains a secret-scan job using gitleaks +# --------------------------------------------------------------------------- +pp_check_secret_scan_ci_job() { + local repo="$1" + + local ci_b64 + ci_b64=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" --jq '.content // ""' 2>/dev/null || echo "") + + if [ -z "$ci_b64" ]; then + add_finding "$repo" "push-protection" "secret_scan_ci_job_present" "error" \ + "No \`.github/workflows/ci.yml\` found — cannot verify the required \`secret-scan\` gitleaks job" \ + "$PP_STANDARD_REF#layer-3--ci-secret-scanning-secondary-defense" + return + fi + + local ci_content + # GitHub returns content base64-encoded, line-wrapped at 60 chars + ci_content=$(echo "$ci_b64" | tr -d '\n ' | base64 -d 2>/dev/null || echo "") + + if [ -z "$ci_content" ]; then + return + fi + + # Match actual action references, not bare mentions in comments or docs. + if ! echo "$ci_content" | grep -qE 'uses:[[:space:]]*(gitleaks/gitleaks-action|zricethezav/gitleaks-action)@'; then + add_finding "$repo" "push-protection" "secret_scan_ci_job_present" "error" \ + "\`ci.yml\` does not contain a job using \`gitleaks\` — add the secret-scan job from the standard" \ + "$PP_STANDARD_REF#required-ci-job" + fi +} + +# --------------------------------------------------------------------------- +# Check: .gitignore contains the baseline secret-protection entries +# --------------------------------------------------------------------------- +pp_check_gitignore_secrets_block() { + local repo="$1" + + local gi_b64 + gi_b64=$(gh_api "repos/$ORG/$repo/contents/.gitignore" --jq '.content // ""' 2>/dev/null || echo "") + + if [ -z "$gi_b64" ]; then + add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \ + "No \`.gitignore\` at repo root — start from the org baseline at /.gitignore" \ + "$PP_STANDARD_REF#required-gitignore-entries" + return + fi + + local gi_content + gi_content=$(echo "$gi_b64" | tr -d '\n ' | base64 -d 2>/dev/null || echo "") + + if [ -z "$gi_content" ]; then + return + fi + + local missing=() + local pattern + for pattern in "${PP_REQUIRED_GITIGNORE_PATTERNS[@]}"; do + # Use fixed-string match anchored to a line; ignore lines that start with `!` + # so a negation can't satisfy the requirement for the broad pattern. + if ! echo "$gi_content" | grep -vE '^[[:space:]]*!' | grep -qxF "$pattern"; then + missing+=("$pattern") + fi + done + + if [ ${#missing[@]} -gt 0 ]; then + add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \ + "\`.gitignore\` is missing baseline secret patterns: $(IFS=', '; echo "${missing[*]}") — copy the org baseline at /.gitignore" \ + "$PP_STANDARD_REF#required-gitignore-entries" + fi +} + +# --------------------------------------------------------------------------- +# Check: recent push-protection bypasses +# --------------------------------------------------------------------------- +# Queries the secret-scanning alerts endpoint and looks for any alert that +# was bypassed via push protection within the lookback window. We can't tell +# from the alert payload whether a bypass had a documented justification, so +# this fires as a `warning` and the human reviewer must verify. +pp_check_push_protection_bypasses() { + local repo="$1" + + local alerts + alerts=$(gh_api "repos/$ORG/$repo/secret-scanning/alerts?per_page=100" 2>/dev/null || echo "[]") + + if ! echo "$alerts" | jq -e 'type == "array"' >/dev/null 2>&1; then + return + fi + + # Cutoff: now - PP_BYPASS_LOOKBACK_DAYS, in ISO-8601 + local cutoff + if date -u -d "$PP_BYPASS_LOOKBACK_DAYS days ago" "+%Y-%m-%dT%H:%M:%SZ" >/dev/null 2>&1; then + # GNU date (Linux / GitHub Actions runners) + cutoff=$(date -u -d "$PP_BYPASS_LOOKBACK_DAYS days ago" "+%Y-%m-%dT%H:%M:%SZ") + else + # BSD date (macOS) fallback + cutoff=$(date -u -v-"${PP_BYPASS_LOOKBACK_DAYS}d" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "") + fi + + if [ -z "$cutoff" ]; then + return + fi + + local recent_bypasses + recent_bypasses=$(echo "$alerts" | jq --arg cutoff "$cutoff" ' + [.[] | select( + .push_protection_bypassed == true and + (.push_protection_bypassed_at != null) and + (.push_protection_bypassed_at >= $cutoff) + )] | length + ' 2>/dev/null || echo "0") + + if [ "$recent_bypasses" -gt 0 ]; then + add_finding "$repo" "push-protection" "push_protection_bypasses_recent" "warning" \ + "$recent_bypasses push-protection bypass(es) in the last $PP_BYPASS_LOOKBACK_DAYS days — verify each had a documented justification" \ + "$PP_STANDARD_REF#bypass-policy" + fi +} + +# --------------------------------------------------------------------------- +# Convenience: run every push-protection check for one repo +# --------------------------------------------------------------------------- +# Callers may call individual checks if they want to interleave with other +# work, but the audit's per-repo loop just calls this single entry point. +pp_run_all_checks() { + local repo="$1" + pp_check_security_and_analysis "$repo" + pp_check_open_secret_alerts "$repo" + pp_check_secret_scan_ci_job "$repo" + pp_check_gitignore_secrets_block "$repo" + pp_check_push_protection_bypasses "$repo" +}