diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20fe8e361..c27cbda2a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,7 +34,7 @@ jobs: run: sudo apt-get install -y bats - name: Run tests - run: bats tests/fleet_report.bats tests/token_report.bats tests/model_pricing.bats tests/test_review_cycle.bats tests/test_ci_status.bats + run: bats tests/fleet_report.bats tests/token_report.bats tests/model_pricing.bats tests/test_review_cycle.bats tests/test_ci_status.bats tests/test_push_protection.bats validate-agent-profiles: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 60d3ca493..304c4a195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ This is the `.github-private` org infrastructure repo for `petry-projects`. It c workflow with no corresponding org template in `standards/workflows/`. It must not be removed by template syncs. If the org template gains a token-report equivalent, remove this exception and defer to the template instead. +- **Exception:** The `bats` test list in `lint.yml` is extended with repo-specific test files (e.g. + `tests/test_push_protection.bats`) beyond the org template baseline. When adding new test files to this + repo, add them to this list. Template syncs must not reset it to the base template list. ### Agent Profiles (`agents/*.md`) diff --git a/scripts/aw-standards-sync.sh b/scripts/aw-standards-sync.sh index a65349ae4..6293a2761 100644 --- a/scripts/aw-standards-sync.sh +++ b/scripts/aw-standards-sync.sh @@ -13,6 +13,9 @@ set -euo pipefail +# shellcheck source=scripts/lib/push-protection.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/push-protection.sh" + ORG="${ORG:-petry-projects}" STANDARDS_REPO="${STANDARDS_REPO:-${ORG}/.github}" REPORT_REPO="${REPORT_REPO:-${ORG}/.github-private}" @@ -26,10 +29,16 @@ REQUIRED_FILES=( ".github/CODEOWNERS|standards/CODEOWNERS" ) -# Required repository security settings: "setting_label|jq_path|api_payload" -REQUIRED_SECURITY_SETTINGS=( - "secret_scanning_ai_detection|.security_and_analysis.secret_scanning_ai_detection.status|{\"security_and_analysis\":{\"secret_scanning_ai_detection\":{\"status\":\"enabled\"}}}" -) +# Required repository security settings derived from PP_REQUIRED_SA_SETTINGS in push-protection.sh. +# Format: "setting_label|jq_path|api_payload" +REQUIRED_SECURITY_SETTINGS=() +for _key in "${PP_REQUIRED_SA_SETTINGS[@]}"; do + _payload=$(jq -n --arg k "$_key" '{"security_and_analysis": {($k): {"status": "enabled"}}}') + REQUIRED_SECURITY_SETTINGS+=( + "${_key}|.security_and_analysis.${_key}.status|${_payload}" + ) +done +unset _key _payload echo "=== Standards Sync ===" echo " Org: ${ORG}" @@ -145,6 +154,11 @@ while IFS= read -r repo; do echo " [warn] ${repo} — could not read ${setting_label}" security_notes="${security_notes} \`${setting_label}\`:error" else + # dependabot_security_updates requires Dependabot alerts to be enabled first. + if [ "$setting_label" = "dependabot_security_updates" ]; then + gh api -X PUT "repos/${repo}/vulnerability-alerts" --silent 2>/dev/null || \ + echo " [warn] ${repo} — could not enable vulnerability alerts (prerequisite for dependabot_security_updates)" + fi if printf '%s' "$api_payload" | \ gh api "repos/${repo}" --method PATCH --input - --silent 2>/dev/null; then settings_applied=$((settings_applied + 1)) diff --git a/scripts/lib/push-protection.sh b/scripts/lib/push-protection.sh new file mode 100644 index 000000000..c37c97da6 --- /dev/null +++ b/scripts/lib/push-protection.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# push-protection.sh — required security_and_analysis settings and apply helper. +# +# Sourced by scripts that need to enforce or audit the push-protection standard: +# https://github.com/petry-projects/.github/blob/main/standards/push-protection.md +# +# PP_REQUIRED_SA_SETTINGS lists the security_and_analysis keys that must be +# "enabled" on every petry-projects repository. Adding a key here +# automatically extends both apply and audit logic that sources this file. +# +# Usage: +# source scripts/lib/push-protection.sh +# REPO=owner/repo pp_apply_security_and_analysis + +set -euo pipefail + +readonly -a PP_REQUIRED_SA_SETTINGS=( + secret_scanning + secret_scanning_push_protection + secret_scanning_ai_detection + secret_scanning_non_provider_patterns + dependabot_security_updates +) + +# pp_apply_security_and_analysis +# Patches all PP_REQUIRED_SA_SETTINGS to "enabled" on $REPO. +# Honours DEV_LEAD_DRY_RUN=true (prints intent, makes no API call). +pp_apply_security_and_analysis() { + : "${REPO:?pp_apply_security_and_analysis: REPO must be set}" + + if [ "${DEV_LEAD_DRY_RUN:-false}" = "true" ]; then + echo "[dry-run] would patch security_and_analysis on ${REPO}: ${PP_REQUIRED_SA_SETTINGS[*]}" + return 0 + fi + + # Enable Dependabot alerts — prerequisite for dependabot_security_updates. + gh api -X PUT "repos/${REPO}/vulnerability-alerts" --silent 2>/dev/null || \ + echo "[warn] ${REPO} — could not enable vulnerability alerts (prerequisite for dependabot_security_updates)" >&2 + + local failures=0 + for key in "${PP_REQUIRED_SA_SETTINGS[@]}"; do + local payload + payload=$(jq -n --arg k "$key" '{"security_and_analysis": {($k): {"status": "enabled"}}}') + if ! gh api -X PATCH "repos/${REPO}" --input - <<<"$payload"; then + echo "[warn] ${REPO} — could not enable ${key} (may require Advanced Security)" >&2 + failures=$((failures + 1)) + fi + done + return 0 +} diff --git a/tests/aw/standards-sync/scenarios.md b/tests/aw/standards-sync/scenarios.md index 8f16b6a9f..4a89ee449 100644 --- a/tests/aw/standards-sync/scenarios.md +++ b/tests/aw/standards-sync/scenarios.md @@ -145,3 +145,37 @@ required additions. - Script continues processing remaining repos after the failed PATCH - Warning message includes the setting name and a note about Advanced Security - `settings_applied` counter does NOT increment for the failed repo + +--- + +## Scenario 10: All PP_REQUIRED_SA_SETTINGS are enforced by the sync + +**Given** a repo is missing any of the settings in `PP_REQUIRED_SA_SETTINGS`: + `secret_scanning`, `secret_scanning_push_protection`, `secret_scanning_ai_detection`, + `secret_scanning_non_provider_patterns`, `dependabot_security_updates` +**When** the monthly schedule fires +**Then** +- the workflow detects each missing setting via `REQUIRED_SECURITY_SETTINGS` (derived from `PP_REQUIRED_SA_SETTINGS` in `scripts/lib/push-protection.sh`) +- each setting is patched individually so that a failure on one does not block the others +- the summary issue notes which settings were enabled and which errored + +**Assertions:** +- All five settings in `PP_REQUIRED_SA_SETTINGS` appear in `REQUIRED_SECURITY_SETTINGS` +- Adding a new key to `PP_REQUIRED_SA_SETTINGS` in the lib automatically extends the sync + +--- + +## Scenario 11: One required security setting unavailable, others still applied + +**Given** a repo has `secret_scanning_ai_detection` unavailable (no Advanced Security licence) + but the other four settings are patchable +**When** the monthly schedule fires +**Then** +- the PATCH for `secret_scanning_ai_detection` returns a 422 error +- the remaining four settings are still patched successfully in their own independent PATCH calls +- the summary issue row for the repo shows which settings were enabled and which errored + +**Assertions:** +- `settings_applied` increments by 4 (the four that succeeded) +- The repo row in the summary notes `secret_scanning_ai_detection:error` alongside the four `:enabled` entries +- Script exits 0 for the run (individual setting failures are non-fatal) diff --git a/tests/test_push_protection.bats b/tests/test_push_protection.bats new file mode 100644 index 000000000..52ae11ad5 --- /dev/null +++ b/tests/test_push_protection.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Unit tests for scripts/lib/push-protection.sh +# +# Run with: bats tests/test_push_protection.bats +# Install bats: https://github.com/bats-core/bats-core + +setup() { + source "$(dirname "$BATS_TEST_FILENAME")/../scripts/lib/push-protection.sh" + # Capture API calls via a temp file so cross-subshell checks work. + GH_LOG="$(mktemp)" + gh() { echo "$*" >> "$GH_LOG"; } + export -f gh + export GH_LOG + unset REPO + unset DEV_LEAD_DRY_RUN +} + +teardown() { + rm -f "${GH_LOG:-}" +} + +# --------------------------------------------------------------------------- +# PP_REQUIRED_SA_SETTINGS — the array must include every required key +# --------------------------------------------------------------------------- + +@test "PP_REQUIRED_SA_SETTINGS includes secret_scanning" { + [[ " ${PP_REQUIRED_SA_SETTINGS[*]} " == *" secret_scanning "* ]] +} + +@test "PP_REQUIRED_SA_SETTINGS includes secret_scanning_push_protection" { + [[ " ${PP_REQUIRED_SA_SETTINGS[*]} " == *" secret_scanning_push_protection "* ]] +} + +@test "PP_REQUIRED_SA_SETTINGS includes secret_scanning_ai_detection" { + [[ " ${PP_REQUIRED_SA_SETTINGS[*]} " == *" secret_scanning_ai_detection "* ]] +} + +@test "PP_REQUIRED_SA_SETTINGS includes secret_scanning_non_provider_patterns" { + [[ " ${PP_REQUIRED_SA_SETTINGS[*]} " == *" secret_scanning_non_provider_patterns "* ]] +} + +@test "PP_REQUIRED_SA_SETTINGS includes dependabot_security_updates" { + [[ " ${PP_REQUIRED_SA_SETTINGS[*]} " == *" dependabot_security_updates "* ]] +} + +# --------------------------------------------------------------------------- +# pp_apply_security_and_analysis — dry-run mode +# --------------------------------------------------------------------------- + +@test "dry-run skips gh api call" { + export DEV_LEAD_DRY_RUN=true + export REPO="owner/repo" + run pp_apply_security_and_analysis + [ "$status" -eq 0 ] + [ ! -s "$GH_LOG" ] +} + +@test "dry-run prints what it would do" { + export DEV_LEAD_DRY_RUN=true + export REPO="owner/repo" + run pp_apply_security_and_analysis + [[ "$output" == *"dry-run"* ]] +} + +# --------------------------------------------------------------------------- +# pp_apply_security_and_analysis — live mode +# --------------------------------------------------------------------------- + +@test "live mode calls gh api PATCH for the repo" { + export DEV_LEAD_DRY_RUN=false + export REPO="owner/repo" + run pp_apply_security_and_analysis + [ "$status" -eq 0 ] + [ -s "$GH_LOG" ] + grep -q "api" "$GH_LOG" + grep -q "PATCH" "$GH_LOG" + grep -q "owner/repo" "$GH_LOG" +} + +@test "live mode PUTs vulnerability-alerts as prerequisite before PATCH loop" { + export DEV_LEAD_DRY_RUN=false + export REPO="owner/repo" + run pp_apply_security_and_analysis + [ "$status" -eq 0 ] + grep -q "vulnerability-alerts" "$GH_LOG" +} + +@test "live mode errors when REPO is unset" { + export DEV_LEAD_DRY_RUN=false + unset REPO + run pp_apply_security_and_analysis + [ "$status" -ne 0 ] +} + +@test "live mode makes one PATCH call per required setting" { + export DEV_LEAD_DRY_RUN=false + export REPO="owner/repo" + gh() { + local stdin_body="" + [ ! -t 0 ] && stdin_body=$(cat) + printf '%s BODY=%s\n' "$*" "$stdin_body" >> "$GH_LOG" + } + export -f gh + run pp_apply_security_and_analysis + [ "$status" -eq 0 ] + local patch_count + patch_count=$(grep -c "PATCH" "$GH_LOG") + [ "$patch_count" -eq "${#PP_REQUIRED_SA_SETTINGS[@]}" ] +} + +@test "live mode continues applying remaining settings after one PATCH failure" { + export DEV_LEAD_DRY_RUN=false + export REPO="owner/repo" + export FAIL_KEY="${PP_REQUIRED_SA_SETTINGS[0]}" + gh() { + local stdin_body="" + [ ! -t 0 ] && stdin_body=$(cat) + printf '%s BODY=%s\n' "$*" "$stdin_body" >> "$GH_LOG" + echo "$stdin_body" | grep -q "\"${FAIL_KEY}\"" && return 1 + return 0 + } + export -f gh + run pp_apply_security_and_analysis + # All settings should be attempted even though the first one fails + local patch_count + patch_count=$(grep -c "PATCH" "$GH_LOG") + [ "$patch_count" -eq "${#PP_REQUIRED_SA_SETTINGS[@]}" ] +} + +@test "live mode returns 0 when some PATCH calls fail (partial failure is non-fatal)" { + export DEV_LEAD_DRY_RUN=false + export REPO="owner/repo" + export FAIL_KEY="${PP_REQUIRED_SA_SETTINGS[0]}" + gh() { + local stdin_body="" + [ ! -t 0 ] && stdin_body=$(cat) + printf '%s BODY=%s\n' "$*" "$stdin_body" >> "$GH_LOG" + echo "$stdin_body" | grep -q "\"${FAIL_KEY}\"" && return 1 + return 0 + } + export -f gh + run pp_apply_security_and_analysis + # A 422 for one unavailable setting (e.g. secret_scanning_ai_detection without GHAS) + # must not abort callers running under set -euo pipefail; exit code must be 0. + [ "$status" -eq 0 ] +}