Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Read the relevant standard *before* making changes that touch CI, repo settings,
| **Agent configuration** | [`standards/agent-standards.md`](https://github.com/petry-projects/.github/blob/main/standards/agent-standards.md) | CLAUDE.md / AGENTS.md / SKILL.md required structure, frontmatter rules, cross-references |
| **Repo settings + labels** | [`standards/github-settings.md`](https://github.com/petry-projects/.github/blob/main/standards/github-settings.md) | Required settings, label set with exact colors, code-quality ruleset, branch protection |
| **Dependabot config** | [`standards/dependabot-policy.md`](https://github.com/petry-projects/.github/blob/main/standards/dependabot-policy.md) and [`standards/dependabot/`](https://github.com/petry-projects/.github/tree/main/standards/dependabot) | Per-ecosystem dependabot.yml templates and policy |
| **Push protection** | [`standards/push-protection.md`](https://github.com/petry-projects/.github/blob/main/standards/push-protection.md) | Secret scanning + push protection, local gitleaks hooks, CI secret-scan job, incident response |

**When fixing a compliance finding, the rule is: read the standard, then copy
the template — do not generate from scratch.** Anything generated from scratch
Expand Down
103 changes: 97 additions & 6 deletions scripts/apply-repo-settings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -76,11 +77,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,
Expand All @@ -92,8 +94,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

Expand Down Expand Up @@ -160,6 +162,79 @@ apply_settings() {
ok "$ORG/$repo settings updated successfully"
}

apply_security_analysis() {
local repo="$1"
local repo_json="$2"
info "Applying security & analysis settings to $ORG/$repo ..."

# Extract current security_and_analysis from the pre-fetched repo JSON
local sa
sa=$(echo "$repo_json" | jq '{
secret_scanning: .security_and_analysis.secret_scanning.status,
secret_scanning_push_protection: .security_and_analysis.secret_scanning_push_protection.status,
secret_scanning_ai_detection: .security_and_analysis.secret_scanning_ai_detection.status,
secret_scanning_non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status,
dependabot_security_updates: .security_and_analysis.dependabot_security_updates.status
}' 2>/dev/null || echo "{}")

if [ "$sa" = "{}" ]; then
err "Could not fetch security_and_analysis for $ORG/$repo — check token permissions"
return 1
fi

# Expected: all features enabled
# standards/push-protection.md#required-repo-level-settings
declare -A SA_EXPECTED=(
[secret_scanning]="enabled"
[secret_scanning_push_protection]="enabled"
[secret_scanning_ai_detection]="enabled"
[secret_scanning_non_provider_patterns]="enabled"
[dependabot_security_updates]="enabled"
)

local needs_patch=false

for key in "${!SA_EXPECTED[@]}"; do
local actual
actual=$(echo "$sa" | jq -r ".$key // \"null\"")
local expected="${SA_EXPECTED[$key]}"

if [ "$actual" != "$expected" ]; then
info " $key: $actual → $expected"
needs_patch=true
else
ok " $key: already $actual"
fi
done

if [ "$needs_patch" = false ]; then
ok "$ORG/$repo security_and_analysis already fully compliant"
return 0
fi

if [ "$DRY_RUN" = "true" ]; then
skip "DRY_RUN=true — skipping security_and_analysis PATCH for $ORG/$repo"
return 0
fi

# Build payload with jq to avoid string-concatenation pitfalls
local payload
payload=$(jq -n '{
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"}
}')

if ! gh api -X PATCH "repos/$ORG/$repo" \
-F "security_and_analysis=$payload" > /dev/null 2>&1; then
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
err "Failed to update security_and_analysis for $ORG/$repo (token may lack admin scope)"
return 1
fi
ok "$ORG/$repo security_and_analysis updated successfully"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
Expand All @@ -185,7 +260,16 @@ 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_security_analysis "$repo" "$repo_json" || failed=$((failed + 1))
apply_labels "$repo"
done

Expand All @@ -196,6 +280,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_security_analysis "$1" "$repo_json"
apply_labels "$1"
fi
143 changes: 140 additions & 3 deletions scripts/compliance-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -268,9 +269,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,
Expand Down Expand Up @@ -304,6 +306,135 @@ check_repo_settings() {

}

# ---------------------------------------------------------------------------
# Check: Push protection (secret scanning + gitleaks CI)
# ---------------------------------------------------------------------------
check_push_protection() {
local repo="$1"
local repo_json="$2"

# --- security_and_analysis flags ---
# Uses the pre-fetched repo JSON to avoid a duplicate API call.
# Checks all 5 settings that apply-repo-settings.sh enforces.
local sa
sa=$(echo "$repo_json" | jq '{
secret_scanning: .security_and_analysis.secret_scanning.status,
push_protection: .security_and_analysis.secret_scanning_push_protection.status,
ai_detection: .security_and_analysis.secret_scanning_ai_detection.status,
non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status,
dependabot_security_updates: .security_and_analysis.dependabot_security_updates.status
}' 2>/dev/null || echo "{}")

if [ "$sa" != "{}" ]; then
local ss_status
ss_status=$(echo "$sa" | jq -r '.secret_scanning // "null"')
if [ "$ss_status" != "enabled" ]; then
add_finding "$repo" "push-protection" "secret_scanning_enabled" "error" \
"Secret scanning is not enabled (current: \`$ss_status\`)" \
"standards/push-protection.md#required-repo-level-settings"
fi

local pp_status
pp_status=$(echo "$sa" | jq -r '.push_protection // "null"')
if [ "$pp_status" != "enabled" ]; then
add_finding "$repo" "push-protection" "push_protection_enabled" "error" \
"Push protection is not enabled (current: \`$pp_status\`)" \
"standards/push-protection.md#required-repo-level-settings"
fi

local ai_status
ai_status=$(echo "$sa" | jq -r '.ai_detection // "null"')
if [ "$ai_status" != "enabled" ]; then
add_finding "$repo" "push-protection" "ai_detection_enabled" "warning" \
"Secret scanning AI detection not enabled (current: \`$ai_status\`)" \
"standards/push-protection.md#required-repo-level-settings"
fi

local np_status
np_status=$(echo "$sa" | jq -r '.non_provider_patterns // "null"')
if [ "$np_status" != "enabled" ]; then
add_finding "$repo" "push-protection" "non_provider_patterns_enabled" "warning" \
"Secret scanning non-provider patterns not enabled (current: \`$np_status\`)" \
"standards/push-protection.md#required-repo-level-settings"
fi

local ds_status
ds_status=$(echo "$sa" | jq -r '.dependabot_security_updates // "null"')
if [ "$ds_status" != "enabled" ]; then
add_finding "$repo" "push-protection" "dependabot_security_updates_enabled" "warning" \
"Dependabot security updates not enabled (current: \`$ds_status\`)" \
"standards/push-protection.md#required-repo-level-settings"
fi
fi

# --- Open secret scanning alerts ---
local alert_count=0
local alert_raw
if alert_raw=$(gh_api "repos/$ORG/$repo/secret-scanning/alerts?state=open" 2>/dev/null); then
alert_count=$(echo "$alert_raw" | jq 'length' 2>/dev/null || echo "0")
fi
if [ "$alert_count" -gt 0 ]; then
add_finding "$repo" "push-protection" "open_secret_alerts" "error" \
"$alert_count open secret scanning alert(s) — rotate and remediate immediately" \
"standards/push-protection.md#incident-response"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# --- CI secret-scan job using gitleaks ---
local ci_content
ci_content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" --jq '.content' 2>/dev/null || echo "")
if [ -n "$ci_content" ]; then
local ci_decoded
ci_decoded=$(echo "$ci_content" | base64 -d 2>/dev/null || echo "")
if [ -n "$ci_decoded" ] && ! echo "$ci_decoded" | grep -q 'gitleaks'; then
add_finding "$repo" "push-protection" "secret_scan_ci_job_present" "error" \
"CI workflow \`ci.yml\` does not contain a \`gitleaks\` secret-scan job" \
"standards/push-protection.md#layer-3--ci-secret-scanning-secondary-defense"
fi
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# --- .gitignore secrets block ---
local gi_content
gi_content=$(gh_api "repos/$ORG/$repo/contents/.gitignore" --jq '.content' 2>/dev/null || echo "")
if [ -n "$gi_content" ]; then
local gi_decoded
gi_decoded=$(echo "$gi_content" | base64 -d 2>/dev/null || echo "")
local missing_entries=()
for pattern in '.env' '*.pem' '*.key'; do
if ! echo "$gi_decoded" | grep -qF "$pattern"; then
missing_entries+=("$pattern")
fi
done
if [ ${#missing_entries[@]} -gt 0 ]; then
add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \
".gitignore missing secret-related entries: ${missing_entries[*]}" \
"standards/push-protection.md#required-gitignore-entries"
fi
else
add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \
"No \`.gitignore\` file found — secrets-related entries are required" \
"standards/push-protection.md#required-gitignore-entries"
fi

# --- Recent push protection bypasses (last 30 days) ---
local bypass_count=0
local bypass_raw
if bypass_raw=$(gh_api "repos/$ORG/$repo/secret-scanning/push-protection-bypasses" 2>/dev/null); then
# Filter to last 30 days
local cutoff
cutoff=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
if [ -n "$cutoff" ]; then
bypass_count=$(echo "$bypass_raw" | jq --arg cutoff "$cutoff" '[.[] | select(.created_at > $cutoff)] | length' 2>/dev/null || echo "0")
else
bypass_count=$(echo "$bypass_raw" | jq 'length' 2>/dev/null || echo "0")
fi
fi
if [ "$bypass_count" -gt 0 ]; then
add_finding "$repo" "push-protection" "push_protection_bypasses_recent" "warning" \
"$bypass_count push protection bypass(es) in the last 30 days — verify each has a documented justification" \
"standards/push-protection.md#what-to-do-when-push-protection-blocks-your-push"
fi
}

# ---------------------------------------------------------------------------
# Check: Required labels
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -765,6 +896,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"
Expand Down Expand Up @@ -953,7 +1085,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
Expand Down Expand Up @@ -1015,10 +1147,15 @@ 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 "{}")

check_required_workflows "$repo"
check_action_pinning "$repo"
check_dependabot_config "$repo"
check_repo_settings "$repo"
check_repo_settings "$repo" "$repo_json"
check_push_protection "$repo" "$repo_json"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
check_labels "$repo"
check_rulesets "$repo"
check_codeowners "$repo"
Expand Down
23 changes: 20 additions & 3 deletions standards/github-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ SHOULD be audited and brought into compliance.
| **Has Wiki** | `false` | Disabled — documentation lives in the repo |
| **Has Discussions** | `true` | **Required** — enables Discussions for ideation, feedback, and community engagement (see [Discussions Configuration](#discussions-configuration)) |

### Security & Analysis

All repositories MUST have the following security features enabled. These are
the enforcement primitives behind the [Push Protection Standard](push-protection.md).

| Setting | Standard Value | Rationale |
|---------|---------------|-----------|
| **Secret scanning** | `enabled` | Detect leaked credentials in history and new commits |
| **Secret scanning push protection** | `enabled` | Block pushes containing known secret patterns at the server side |
| **Secret scanning AI detection** | `enabled` | Catch generic secrets missed by regex patterns |
| **Secret scanning non-provider patterns** | `enabled` | Private keys, HTTP basic auth, high-entropy strings |
| **Dependabot security updates** | `enabled` | Automated patches for known-vulnerable dependencies |

> See the full requirements, custom patterns, CI job, incident response flow,
> and compliance audit checks in [`push-protection.md`](push-protection.md).

### Merge Settings

| Setting | Standard Value | Rationale |
Expand Down Expand Up @@ -126,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

Expand All @@ -139,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

Expand Down
Loading
Loading