diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh index 09dbdb6..3a5fc84 100644 --- a/scripts/apply-repo-settings.sh +++ b/scripts/apply-repo-settings.sh @@ -3,6 +3,7 @@ # # 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: @@ -82,11 +83,12 @@ apply_labels() { apply_settings() { local repo="$1" + local repo_json="$2" info "Applying standard settings to $ORG/$repo ..." - # Fetch current settings + # Extract current settings from the pre-fetched repo JSON local current - current=$(gh api "repos/$ORG/$repo" --jq '{ + 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, @@ -98,8 +100,8 @@ apply_settings() { squash_merge_commit_message: .squash_merge_commit_message }' 2>/dev/null || echo "{}") - if [ "$current" = "{}" ]; then - err "Could not fetch settings for $ORG/$repo — check token permissions and repo name" + if [ "$current" = "{}" ] || [ "$current" = "null" ]; then + err "Could not parse settings for $ORG/$repo" return 1 fi @@ -249,7 +251,15 @@ if [ "$1" = "--all" ]; then failed=0 for repo in $repos; do - apply_settings "$repo" || failed=$((failed + 1)) + # 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)) @@ -262,7 +272,13 @@ if [ "$1" = "--all" ]; then ok "All repos processed successfully" else - apply_settings "$1" + 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" diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh index 3a95921..fda7c91 100755 --- a/scripts/apply-rulesets.sh +++ b/scripts/apply-rulesets.sh @@ -135,11 +135,14 @@ detect_required_checks() { if echo "$workflows" | grep -qx "ci.yml"; then local ci_wf_name ci_wf_name=$(workflow_name "ci.yml") + # Fetch ci.yml content once; used for first-job detection and secret-scan check below + local ci_content ci_decoded + ci_content=$(gh api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" \ + --jq '.content' 2>/dev/null || echo "") + ci_decoded=$(echo "$ci_content" | base64 -d 2>/dev/null || echo "") # Fetch the first job name from ci.yml local ci_job_name - ci_job_name=$(gh api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" \ - --jq '.content' 2>/dev/null \ - | base64 -d 2>/dev/null \ + ci_job_name=$(echo "$ci_decoded" \ | awk ' /^jobs:/ { in_jobs=1; found=0; next } in_jobs && /^ [a-zA-Z0-9_-]+:/ && !found { @@ -158,6 +161,11 @@ detect_required_checks() { else checks+=("$ci_job_name") fi + + # --- Secret scan (gitleaks) — included when ci.yml contains the gitleaks action --- + if echo "$ci_decoded" | grep -qE 'uses:[[:space:]]*(gitleaks/gitleaks-action|zricethezav/gitleaks-action)@'; then + checks+=("Secret scan (gitleaks)") + fi fi # Output as newline-separated list (guard against empty array with set -u) diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index 418d9b8..b95f187 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -5,6 +5,7 @@ # standards/ci-standards.md # standards/dependabot-policy.md # standards/github-settings.md +# standards/push-protection.md # # Outputs: # $REPORT_DIR/findings.json — machine-readable findings @@ -283,9 +284,10 @@ check_dependabot_config() { # --------------------------------------------------------------------------- check_repo_settings() { local repo="$1" + local repo_json="$2" local settings - settings=$(gh_api "repos/$ORG/$repo" --jq '{ + settings=$(echo "$repo_json" | jq '{ allow_auto_merge: .allow_auto_merge, delete_branch_on_merge: .delete_branch_on_merge, has_wiki: .has_wiki, @@ -968,6 +970,7 @@ create_umbrella_issue() { # Each group: category_keys|display_name|remediation_script local groups=( "settings|Repository Settings|apply-repo-settings.sh" + "push-protection|Push Protection & Secret Scanning|apply-repo-settings.sh (security_and_analysis) + per-repo ci.yml and .gitignore" "labels|Labels|apply_labels() in apply-repo-settings.sh" "rulesets|Repository Rulesets|apply-rulesets.sh" "ci-workflows|Workflows|per-repo workflow additions" @@ -1156,7 +1159,7 @@ HEREDOC HEREDOC - for category in ci-workflows action-pinning dependabot settings labels rulesets standards; do + for category in ci-workflows action-pinning dependabot settings push-protection labels rulesets standards; do local cat_count cat_count=$(jq --arg cat "$category" '[.[] | select(.category == $cat)] | length' "$FINDINGS_FILE") if [ "$cat_count" -gt 0 ]; then @@ -1218,10 +1221,21 @@ main() { info "Detected ecosystems: ${ECOSYSTEMS[*]}" fi + # Fetch full repo JSON once and share with settings/push-protection checks + local repo_json + repo_json=$(gh_api "repos/$ORG/$repo" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + add_finding "$repo" "settings" "repo_metadata_unavailable" "error" \ + "Could not fetch repository metadata; settings and push-protection checks were skipped" \ + "standards/github-settings.md#repository-settings--standard-defaults" + log_end + continue + fi + check_required_workflows "$repo" check_action_pinning "$repo" check_dependabot_config "$repo" - check_repo_settings "$repo" + check_repo_settings "$repo" "$repo_json" check_labels "$repo" check_rulesets "$repo" check_codeowners "$repo" diff --git a/scripts/lib/push-protection.sh b/scripts/lib/push-protection.sh index 4f340d5..9f1ea5d 100644 --- a/scripts/lib/push-protection.sh +++ b/scripts/lib/push-protection.sh @@ -200,7 +200,8 @@ pp_check_secret_scan_ci_job() { return fi - if ! echo "$ci_content" | grep -qiE 'gitleaks'; then + # 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" diff --git a/standards/github-settings.md b/standards/github-settings.md index 04ec2f6..3b6cfea 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -142,9 +142,9 @@ rules are deprecated — migrate existing classic rules to rulesets. ### `code-quality` — Required Checks Ruleset (All Repositories) -Every repository MUST have all five quality checks configured and required. -The specific check names and ecosystem configurations vary by repo, but the -categories are universal. +Every repository MUST have the following quality checks configured and +required. The specific check names and ecosystem configurations vary by repo, +but the categories are universal. #### Required Check Categories @@ -155,6 +155,7 @@ categories are universal. | **Claude Code** | All repos | `claude` | AI code review on every PR | | **CI Pipeline** | All repos | Repo-specific (e.g., `build-and-test`, `TypeScript`, `Go`) | Lint, format, typecheck, test | | **Coverage** | All repos | `coverage` or embedded in CI job | Must meet repo-defined thresholds | +| **Secret Scan** | All repos | `Secret scan (gitleaks)` | Full-history gitleaks scan — see [Push Protection Standard](push-protection.md#layer-3--ci-secret-scanning-secondary-defense) | #### Ecosystem-Specific Configuration diff --git a/standards/push-protection.md b/standards/push-protection.md index 4444f72..05d3fe9 100644 --- a/standards/push-protection.md +++ b/standards/push-protection.md @@ -100,14 +100,17 @@ compliance audit checks these flags via `GET /repos/{owner}/{repo}`: Apply per repo via: ```bash -gh api -X PATCH "repos/petry-projects/" \ - -F security_and_analysis='{ +gh api -X PATCH "repos/petry-projects/" --input - <<'JSON' +{ + "security_and_analysis": { "secret_scanning": {"status": "enabled"}, "secret_scanning_push_protection": {"status": "enabled"}, "secret_scanning_ai_detection": {"status": "enabled"}, "secret_scanning_non_provider_patterns": {"status": "enabled"}, "dependabot_security_updates": {"status": "enabled"} - }' + } +} +JSON ``` `scripts/apply-repo-settings.sh` MUST enforce these values alongside the @@ -124,12 +127,12 @@ weekly audit. See [Application](#application-to-a-repository) below. The org MUST configure the following custom patterns in addition to the provider-supplied ones: -| Pattern name | Regex (illustrative) | Rationale | -|--------------|----------------------|-----------| +| Pattern name | Pattern (illustrative) | Rationale | +|--------------|------------------------|-----------| | `petry-internal-webhook` | `https://hooks\.petry-projects\.internal/[A-Za-z0-9/_-]{20,}` | Internal webhook URLs | | `claude-oauth-token` | `sk-ant-oat01-[A-Za-z0-9_-]{40,}` | Anthropic OAuth tokens | | `gha-pat-scoped` | `github_pat_[A-Za-z0-9_]{82}` | Fine-grained GitHub PATs (provider pattern supplements) | -| `generic-high-entropy` | High-entropy strings assigned to `*_TOKEN`, `*_SECRET`, `*_KEY` env vars | Catches untyped long strings in YAML and `.env` files | +| `generic-high-entropy` | `(?:_TOKEN\|_SECRET\|_KEY)\s*[:=]\s*["']?[A-Za-z0-9/+=_-]{32,}` | Catches untyped long strings in YAML and `.env` files | Custom patterns are configured at **Org settings → Code security → Secret scanning → Custom patterns**. Each new pattern MUST be dry-run against all @@ -414,7 +417,7 @@ both at once: | `non_provider_patterns_enabled` | warning | `security_and_analysis.secret_scanning_non_provider_patterns.status == "enabled"` | | `dependabot_security_updates_enabled` | warning | `security_and_analysis.dependabot_security_updates.status == "enabled"` | | `open_secret_alerts` | error | `GET /repos/{owner}/{repo}/secret-scanning/alerts?state=open` returns an empty array | -| `secret_scan_ci_job_present` | error | `.github/workflows/ci.yml` contains a job using `gitleaks` | +| `secret_scan_ci_job_present` | error | `.github/workflows/ci.yml` contains a job using `gitleaks/gitleaks-action` | | `gitignore_secrets_block` | warning | `.gitignore` contains `.env`, `*.pem`, `*.key` entries | | `push_protection_bypasses_recent` | warning | No bypasses in the last 30 days without a documented justification |