From 9394b07d9200a551bd26a78268931a5a638e84cb Mon Sep 17 00:00:00 2001 From: DJ Date: Sun, 5 Apr 2026 10:16:17 -0700 Subject: [PATCH 1/4] feat: add weekly compliance audit workflow Adds automated weekly audit that checks all petry-projects repos against org standards (CI, Dependabot, settings, labels, rulesets) and creates/updates/closes issues for each finding. - Deterministic shell script for reliable, repeatable checks - Claude Code Action job for standards improvement research - Issues auto-assigned to Claude for remediation - Summary notification for org owners - Idempotent: updates existing issues, closes resolved ones Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/compliance-audit.yml | 158 ++++++ scripts/compliance-audit.sh | 675 +++++++++++++++++++++++++ 2 files changed, 833 insertions(+) create mode 100644 .github/workflows/compliance-audit.yml create mode 100755 scripts/compliance-audit.sh diff --git a/.github/workflows/compliance-audit.yml b/.github/workflows/compliance-audit.yml new file mode 100644 index 0000000..c0cab31 --- /dev/null +++ b/.github/workflows/compliance-audit.yml @@ -0,0 +1,158 @@ +name: Weekly Compliance Audit + +on: + schedule: + - cron: '0 8 * * 1' # Every Monday at 8:00 UTC (before org-scorecard at 9:00) + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — audit only, skip issue creation' + required: false + default: 'false' + type: boolean + +permissions: {} + +jobs: + # ----------------------------------------------------------------------- + # Job 1: Deterministic compliance checks + # Runs the shell script that audits all repos against org standards. + # Produces a JSON findings file and markdown summary. + # Creates/updates/closes GitHub Issues for each finding. + # ----------------------------------------------------------------------- + audit: + name: Compliance Audit + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ secrets.ORG_SCORECARD_TOKEN }} + outputs: + findings_count: ${{ steps.audit.outputs.findings_count }} + error_count: ${{ steps.audit.outputs.error_count }} + warning_count: ${{ steps.audit.outputs.warning_count }} + repo_count: ${{ steps.audit.outputs.repo_count }} + steps: + - name: Checkout .github repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run compliance audit + id: audit + env: + REPORT_DIR: ${{ runner.temp }}/compliance-report + DRY_RUN: ${{ inputs.dry_run || 'false' }} + CREATE_ISSUES: 'true' + run: | + mkdir -p "$REPORT_DIR" + bash scripts/compliance-audit.sh + + # Parse outputs for downstream jobs + FINDINGS_COUNT=$(jq length "$REPORT_DIR/findings.json") + ERROR_COUNT=$(jq '[.[] | select(.severity == "error")] | length' "$REPORT_DIR/findings.json") + WARNING_COUNT=$(jq '[.[] | select(.severity == "warning")] | length' "$REPORT_DIR/findings.json") + REPO_COUNT=$(jq '[.[].repo] | unique | length' "$REPORT_DIR/findings.json") + + echo "findings_count=$FINDINGS_COUNT" >> "$GITHUB_OUTPUT" + echo "error_count=$ERROR_COUNT" >> "$GITHUB_OUTPUT" + echo "warning_count=$WARNING_COUNT" >> "$GITHUB_OUTPUT" + echo "repo_count=$REPO_COUNT" >> "$GITHUB_OUTPUT" + + - name: Write step summary + if: always() + run: | + if [ -f "${{ runner.temp }}/compliance-report/summary.md" ]; then + cat "${{ runner.temp }}/compliance-report/summary.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "Audit script did not produce a summary." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: compliance-report + path: ${{ runner.temp }}/compliance-report/ + retention-days: 90 + + # ----------------------------------------------------------------------- + # Job 2: AI-powered standards analysis + # Uses Claude Code Action to review the audit findings, research potential + # improvements to the org standards themselves, and post a summary + # notification for org owners. + # ----------------------------------------------------------------------- + standards-review: + name: Standards Review (Claude) + needs: audit + if: always() && needs.audit.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + id-token: write + steps: + - name: Checkout .github repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download audit report + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: compliance-report + path: ${{ runner.temp }}/compliance-report + + - name: Run Claude Code for standards review + env: + GH_TOKEN: ${{ secrets.ORG_SCORECARD_TOKEN }} + uses: anthropics/claude-code-action@bee87b3258c251f9279e5371b0cc3660f37f3f77 # v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + direct_prompt: | + You are performing a weekly standards review for the petry-projects GitHub organization. + The compliance audit has already run and produced findings. + + ## Audit Data + + - Total findings: ${{ needs.audit.outputs.findings_count }} + - Errors: ${{ needs.audit.outputs.error_count }} + - Warnings: ${{ needs.audit.outputs.warning_count }} + - Repos with findings: ${{ needs.audit.outputs.repo_count }} + - Findings JSON: ${{ runner.temp }}/compliance-report/findings.json + - Summary report: ${{ runner.temp }}/compliance-report/summary.md + - Workflow run: https://github.com/petry-projects/.github/actions/runs/${{ github.run_id }} + + ## Task 1: Research Standards Improvements + + Read the current org standards in the `standards/` directory: + - `standards/ci-standards.md` + - `standards/dependabot-policy.md` + - `standards/github-settings.md` + - `AGENTS.md` + + Also read the findings JSON and summary report at the paths above. + + Research and identify gaps or improvements to the standards. Consider: + - Missing standards modern GitHub orgs should have (secret scanning, push protection, Dependabot auto-triage) + - Newer versions of tools/actions referenced in standards + - Inconsistencies between standards documents + - Industry best practices not yet covered + + For each improvement, create a GitHub Issue in `petry-projects/.github` with: + - Title: "Standards: " + - Label: `enhancement` + - Body: current state, proposed improvement, rationale, implementation steps + + Before creating, search for existing open issues to avoid duplicates. + Only create genuinely valuable improvements. Max 3 new issues per run. + + ## Task 2: Post Summary Notification + + Create a notification issue in `petry-projects/.github` titled: + "Weekly Compliance Audit Summary — YYYY-MM-DD" (use today's date). + + Include: executive summary, top priority items, workflow run link, + any new standards improvement issues you created. Label: `compliance-audit`. + + Close any previous "Weekly Compliance Audit Summary" issues first. + allowed_tools: "Bash,Read,Glob,Grep" + timeout_minutes: 20 diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh new file mode 100755 index 0000000..d9806e5 --- /dev/null +++ b/scripts/compliance-audit.sh @@ -0,0 +1,675 @@ +#!/usr/bin/env bash +# compliance-audit.sh — Weekly org-wide compliance audit +# +# Checks every petry-projects repository against the standards defined in: +# standards/ci-standards.md +# standards/dependabot-policy.md +# standards/github-settings.md +# +# Outputs: +# $REPORT_DIR/findings.json — machine-readable findings +# $REPORT_DIR/summary.md — human-readable report +# +# Environment variables: +# GH_TOKEN — GitHub token with repo/org scope (required) +# REPORT_DIR — directory for output files (default: mktemp -d) +# DRY_RUN — set to "true" to skip issue creation (default: false) +# CREATE_ISSUES — set to "false" to skip issue creation (default: true) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +ORG="petry-projects" +AUDIT_LABEL="compliance-audit" +AUDIT_LABEL_COLOR="7057ff" +AUDIT_LABEL_DESC="Automated compliance audit finding" +REPORT_DIR="${REPORT_DIR:-$(mktemp -d)}" +DRY_RUN="${DRY_RUN:-false}" +CREATE_ISSUES="${CREATE_ISSUES:-true}" + +FINDINGS_FILE="$REPORT_DIR/findings.json" +SUMMARY_FILE="$REPORT_DIR/summary.md" + +REQUIRED_WORKFLOWS=(ci.yml codeql.yml sonarcloud.yml claude.yml dependabot-automerge.yml dependency-audit.yml) + +REQUIRED_LABELS=(security dependencies scorecard bug enhancement documentation) + +REQUIRED_SETTINGS_BOOL=( + "allow_auto_merge:true:Allow auto-merge must be enabled for Dependabot workflow" + "delete_branch_on_merge:true:Automatically delete head branches must be enabled" + "has_wiki:false:Wiki should be disabled — documentation lives in the repo" +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +findings_count=0 + +add_finding() { + local repo="$1" category="$2" check="$3" severity="$4" detail="$5" standard_ref="${6:-}" + + findings_count=$((findings_count + 1)) + local finding + finding=$(jq -n \ + --arg repo "$repo" \ + --arg category "$category" \ + --arg check "$check" \ + --arg severity "$severity" \ + --arg detail "$detail" \ + --arg standard_ref "$standard_ref" \ + '{repo:$repo,category:$category,check:$check,severity:$severity,detail:$detail,standard_ref:$standard_ref}') + + jq --argjson f "$finding" '. += [$f]' "$FINDINGS_FILE" > "$FINDINGS_FILE.tmp" + mv "$FINDINGS_FILE.tmp" "$FINDINGS_FILE" +} + +log() { echo "::group::$*" >&2; } +log_end() { echo "::endgroup::" >&2; } +info() { echo "[INFO] $*" >&2; } +warn() { echo "::warning::$*" >&2; } + +# Retry wrapper for gh api calls (handles rate limits) +gh_api() { + local retries=3 + for i in $(seq 1 $retries); do + if gh api "$@" 2>/dev/null; then + return 0 + fi + if [ "$i" -lt "$retries" ]; then + sleep $((i * 2)) + fi + done + return 1 +} + +# --------------------------------------------------------------------------- +# Ecosystem detection +# --------------------------------------------------------------------------- +detect_ecosystems() { + local repo="$1" + ECOSYSTEMS=() + + # Check for common ecosystem markers via the repo tree + local tree + tree=$(gh_api "repos/$ORG/$repo/git/trees/HEAD?recursive=1" --jq '.tree[].path' 2>/dev/null || echo "") + + if echo "$tree" | grep -qE '(^|/)package\.json$'; then + ECOSYSTEMS+=("npm") + fi + if echo "$tree" | grep -qE '(^|/)pnpm-lock\.yaml$'; then + # Override npm with pnpm if lock file present + ECOSYSTEMS=("${ECOSYSTEMS[@]/npm/pnpm}") + fi + if echo "$tree" | grep -qE '(^|/)go\.mod$'; then + ECOSYSTEMS+=("go") + fi + if echo "$tree" | grep -qE '(^|/)Cargo\.toml$'; then + ECOSYSTEMS+=("rust") + fi + if echo "$tree" | grep -qE '(^|/)(pyproject\.toml|requirements\.txt)$'; then + ECOSYSTEMS+=("python") + fi + if echo "$tree" | grep -qE '\.tf$'; then + ECOSYSTEMS+=("terraform") + fi + if echo "$tree" | grep -qE '\.github/workflows/.*\.yml$'; then + ECOSYSTEMS+=("github-actions") + fi +} + +# --------------------------------------------------------------------------- +# Check: Required workflows exist +# --------------------------------------------------------------------------- +check_required_workflows() { + local repo="$1" + + for wf in "${REQUIRED_WORKFLOWS[@]}"; do + if ! gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.name' > /dev/null 2>&1; then + add_finding "$repo" "ci-workflows" "missing-$wf" "error" \ + "Required workflow \`$wf\` is missing" \ + "standards/ci-standards.md#required-workflows" + fi + done +} + +# --------------------------------------------------------------------------- +# Check: Action SHA pinning +# --------------------------------------------------------------------------- +check_action_pinning() { + local repo="$1" + + # List workflow files + local workflows + workflows=$(gh_api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null || echo "") + + for wf in $workflows; do + [[ "$wf" != *.yml && "$wf" != *.yaml ]] && continue + + local content + content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") + [ -z "$content" ] && continue + + local decoded + decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") + [ -z "$decoded" ] && continue + + # Find uses: directives that are NOT SHA-pinned + # SHA-pinned: uses: owner/action@<40+ hex chars> + # Exclude docker:// and ./ references + local unpinned + unpinned=$(echo "$decoded" | grep -E '^\s*-?\s*uses:\s+[^#]*@' | grep -vE '@[0-9a-f]{40}' | grep -vE '(docker://|\.\/)' || true) + + if [ -n "$unpinned" ]; then + local count + count=$(echo "$unpinned" | wc -l | tr -d ' ') + local examples + examples=$(echo "$unpinned" | head -3 | sed 's/^[[:space:]]*//' | paste -sd ', ' -) + add_finding "$repo" "action-pinning" "unpinned-actions-$wf" "error" \ + "Workflow \`$wf\` has $count action(s) not pinned to SHA: $examples" \ + "standards/ci-standards.md#action-pinning-policy" + fi + done +} + +# --------------------------------------------------------------------------- +# Check: Dependabot configuration +# --------------------------------------------------------------------------- +check_dependabot_config() { + local repo="$1" + + local content + content=$(gh_api "repos/$ORG/$repo/contents/.github/dependabot.yml" --jq '.content' 2>/dev/null || echo "") + + if [ -z "$content" ]; then + add_finding "$repo" "dependabot" "missing-config" "error" \ + "Missing \`.github/dependabot.yml\` configuration file" \ + "standards/dependabot-policy.md" + return + fi + + local decoded + decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") + + # Check github-actions ecosystem entry exists + if ! echo "$decoded" | grep -q 'package-ecosystem:.*"github-actions"'; then + add_finding "$repo" "dependabot" "missing-github-actions-ecosystem" "error" \ + "Dependabot config missing \`github-actions\` ecosystem entry" \ + "standards/dependabot-policy.md#github-actions-all-repos" + fi + + # Check that app ecosystem entries use open-pull-requests-limit: 0 + # Extract ecosystem blocks and check limits + for eco in npm pip gomod cargo terraform; do + if echo "$decoded" | grep -q "package-ecosystem:.*\"$eco\""; then + # Check if this ecosystem has limit: 0 + # Simple heuristic: find the ecosystem line and look for limit in the next ~10 lines + local block + block=$(echo "$decoded" | awk "/package-ecosystem:.*\"$eco\"/{found=1} found{print; if(/package-ecosystem:/ && NR>1 && !/\"$eco\"/) exit}" | head -15) + local limit + limit=$(echo "$block" | grep 'open-pull-requests-limit:' | head -1 | grep -oE '[0-9]+' || echo "") + if [ -n "$limit" ] && [ "$limit" != "0" ]; then + add_finding "$repo" "dependabot" "wrong-limit-$eco" "warning" \ + "Dependabot \`$eco\` ecosystem has \`open-pull-requests-limit: $limit\` (should be \`0\` for security-only policy)" \ + "standards/dependabot-policy.md#policy" + fi + fi + done + + # Check for required labels in dependabot config + if ! echo "$decoded" | grep -q '"security"'; then + add_finding "$repo" "dependabot" "missing-security-label" "warning" \ + "Dependabot config missing \`security\` label on updates" \ + "standards/dependabot-policy.md#policy" + fi + if ! echo "$decoded" | grep -q '"dependencies"'; then + add_finding "$repo" "dependabot" "missing-dependencies-label" "warning" \ + "Dependabot config missing \`dependencies\` label on updates" \ + "standards/dependabot-policy.md#policy" + fi +} + +# --------------------------------------------------------------------------- +# Check: Repository settings +# --------------------------------------------------------------------------- +check_repo_settings() { + local repo="$1" + + local settings + settings=$(gh_api "repos/$ORG/$repo" --jq '{ + allow_auto_merge: .allow_auto_merge, + delete_branch_on_merge: .delete_branch_on_merge, + has_wiki: .has_wiki, + has_discussions: .has_discussions, + default_branch: .default_branch, + has_issues: .has_issues + }' 2>/dev/null || echo "{}") + + [ "$settings" = "{}" ] && return + + # Boolean settings checks + for entry in "${REQUIRED_SETTINGS_BOOL[@]}"; do + IFS=':' read -r key expected detail <<< "$entry" + local actual + actual=$(echo "$settings" | jq -r ".$key // \"null\"") + if [ "$actual" != "$expected" ]; then + add_finding "$repo" "settings" "$key" "warning" \ + "$detail (current: \`$actual\`, expected: \`$expected\`)" \ + "standards/github-settings.md#repository-settings--standard-defaults" + fi + done + + # Default branch + local default_branch + default_branch=$(echo "$settings" | jq -r '.default_branch') + if [ "$default_branch" != "main" ]; then + add_finding "$repo" "settings" "default-branch" "error" \ + "Default branch is \`$default_branch\`, should be \`main\`" \ + "standards/github-settings.md#general" + fi + + # Discussions + local has_discussions + has_discussions=$(echo "$settings" | jq -r '.has_discussions') + if [ "$has_discussions" != "true" ]; then + add_finding "$repo" "settings" "has-discussions" "warning" \ + "Discussions should be enabled for community engagement" \ + "standards/github-settings.md#general" + fi +} + +# --------------------------------------------------------------------------- +# Check: Required labels +# --------------------------------------------------------------------------- +check_labels() { + local repo="$1" + + local existing_labels + existing_labels=$(gh_api "repos/$ORG/$repo/labels" --jq '.[].name' --paginate 2>/dev/null || echo "") + + for label in "${REQUIRED_LABELS[@]}"; do + if ! echo "$existing_labels" | grep -qx "$label"; then + add_finding "$repo" "labels" "missing-label-$label" "warning" \ + "Required label \`$label\` is missing" \ + "standards/github-settings.md#labels--standard-set" + fi + done +} + +# --------------------------------------------------------------------------- +# Check: Repository rulesets +# --------------------------------------------------------------------------- +check_rulesets() { + local repo="$1" + + local rulesets + rulesets=$(gh_api "repos/$ORG/$repo/rulesets" --jq '.[].name' 2>/dev/null || echo "") + + if ! echo "$rulesets" | grep -qx "pr-quality"; then + add_finding "$repo" "rulesets" "missing-pr-quality" "error" \ + "Missing \`pr-quality\` repository ruleset" \ + "standards/github-settings.md#pr-quality--standard-ruleset-all-repositories" + fi + + if ! echo "$rulesets" | grep -qx "code-quality"; then + add_finding "$repo" "rulesets" "missing-code-quality" "error" \ + "Missing \`code-quality\` repository ruleset (required status checks)" \ + "standards/github-settings.md#code-quality--required-checks-ruleset-all-repositories" + fi +} + +# --------------------------------------------------------------------------- +# Check: CODEOWNERS +# --------------------------------------------------------------------------- +check_codeowners() { + local repo="$1" + + # CODEOWNERS can be in root, .github/, or docs/ + local found=false + for path in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do + if gh_api "repos/$ORG/$repo/contents/$path" --jq '.name' > /dev/null 2>&1; then + found=true + break + fi + done + + if [ "$found" = false ]; then + add_finding "$repo" "settings" "missing-codeowners" "warning" \ + "No \`CODEOWNERS\` file found — required for code owner review enforcement" \ + "standards/github-settings.md#pr-quality--standard-ruleset-all-repositories" + fi +} + +# --------------------------------------------------------------------------- +# Check: SonarCloud project properties +# --------------------------------------------------------------------------- +check_sonarcloud() { + local repo="$1" + + # Only check if sonarcloud.yml exists + if gh_api "repos/$ORG/$repo/contents/.github/workflows/sonarcloud.yml" --jq '.name' > /dev/null 2>&1; then + if ! gh_api "repos/$ORG/$repo/contents/sonar-project.properties" --jq '.name' > /dev/null 2>&1; then + add_finding "$repo" "ci-workflows" "missing-sonar-properties" "warning" \ + "SonarCloud workflow exists but \`sonar-project.properties\` is missing" \ + "standards/ci-standards.md#3-sonarcloud-analysis-sonarcloudyml" + fi + fi +} + +# --------------------------------------------------------------------------- +# Check: Workflow permissions follow least-privilege +# --------------------------------------------------------------------------- +check_workflow_permissions() { + local repo="$1" + + local workflows + workflows=$(gh_api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null || echo "") + + for wf in $workflows; do + [[ "$wf" != *.yml && "$wf" != *.yaml ]] && continue + + local content + content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") + [ -z "$content" ] && continue + + local decoded + decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") + [ -z "$decoded" ] && continue + + # Check if the workflow has a top-level permissions key + # A well-configured multi-job workflow resets permissions to {} at top level + if ! echo "$decoded" | grep -qE '^permissions:'; then + add_finding "$repo" "ci-workflows" "missing-permissions-$wf" "warning" \ + "Workflow \`$wf\` missing top-level \`permissions:\` declaration (least-privilege policy)" \ + "standards/ci-standards.md#permissions-policy" + fi + done +} + +# --------------------------------------------------------------------------- +# Issue management +# --------------------------------------------------------------------------- +ensure_audit_label() { + local repo="$1" + gh label create "$AUDIT_LABEL" \ + --repo "$ORG/$repo" \ + --description "$AUDIT_LABEL_DESC" \ + --color "$AUDIT_LABEL_COLOR" \ + --force 2>/dev/null || true +} + +create_issue_for_finding() { + local repo="$1" category="$2" check="$3" severity="$4" detail="$5" standard_ref="$6" + + local title="Compliance: ${check}" + # Normalize title for search + local search_title="${title}" + + # Check for existing open issue with same title + local existing + existing=$(gh issue list --repo "$ORG/$repo" \ + --label "$AUDIT_LABEL" \ + --state open \ + --search "\"$search_title\" in:title" \ + --json number,title \ + -q ".[] | select(.title == \"$search_title\") | .number" \ + 2>/dev/null | head -1 || echo "") + + if [ -n "$existing" ]; then + # Update existing issue with a comment + gh issue comment "$existing" --repo "$ORG/$repo" \ + --body "**Weekly Compliance Audit** ($(date -u +%Y-%m-%d)) + +This finding is still open. + +**Detail:** $detail + +**Standard:** [$standard_ref](https://github.com/$ORG/.github/blob/main/$standard_ref)" 2>/dev/null || true + info "Updated existing issue #$existing in $repo for: $check" + return + fi + + # Create new issue + local body + body=$(cat </dev/null || echo "") + + if [ -n "$issue_url" ]; then + local new_issue + new_issue=$(echo "$issue_url" | grep -oE '[0-9]+$' || echo "") + info "Created issue #$new_issue in $repo for: $check ($issue_url)" + + # Attempt to assign to claude — the bot user for Claude Code Action + if [ -n "$new_issue" ]; then + gh issue edit "$new_issue" --repo "$ORG/$repo" --add-assignee "app/claude" 2>/dev/null || true + fi + else + warn "Failed to create issue in $repo for: $check" + fi +} + +close_resolved_issues() { + local repo="$1" + + # Get all open compliance-audit issues + local open_issues + open_issues=$(gh issue list --repo "$ORG/$repo" \ + --label "$AUDIT_LABEL" \ + --state open \ + --json number,title \ + -q '.[] | "\(.number)\t\(.title)"' 2>/dev/null || echo "") + + [ -z "$open_issues" ] && return + + # Get current findings for this repo + local current_checks + current_checks=$(jq -r --arg repo "$repo" '.[] | select(.repo == $repo) | .check' "$FINDINGS_FILE") + + while IFS=$'\t' read -r issue_num issue_title; do + # Extract the check name from the title "Compliance: " + local check_name="${issue_title#Compliance: }" + + # If this check is no longer in findings, close the issue + if ! echo "$current_checks" | grep -qx "$check_name"; then + gh issue close "$issue_num" --repo "$ORG/$repo" \ + --comment "Resolved! This check is now passing as of $(date -u +%Y-%m-%d). Closing automatically." \ + 2>/dev/null || true + info "Closed resolved issue #$issue_num in $repo: $issue_title" + fi + done <<< "$open_issues" +} + +# --------------------------------------------------------------------------- +# Summary generation +# --------------------------------------------------------------------------- +generate_summary() { + local total_repos="$1" + local total_findings + total_findings=$(jq length "$FINDINGS_FILE") + + local error_count + error_count=$(jq '[.[] | select(.severity == "error")] | length' "$FINDINGS_FILE") + local warning_count + warning_count=$(jq '[.[] | select(.severity == "warning")] | length' "$FINDINGS_FILE") + + cat > "$SUMMARY_FILE" <> "$SUMMARY_FILE" + return + fi + + for repo in $repos_with_findings; do + local repo_findings + repo_findings=$(jq -r --arg repo "$repo" \ + '.[] | select(.repo == $repo) | "| `\(.severity)` | \(.category) | \(.check) | \(.detail) |"' \ + "$FINDINGS_FILE") + + local repo_count + repo_count=$(jq --arg repo "$repo" '[.[] | select(.repo == $repo)] | length' "$FINDINGS_FILE") + + cat >> "$SUMMARY_FILE" <> "$SUMMARY_FILE" <> "$SUMMARY_FILE" + fi + done + + cat >> "$SUMMARY_FILE" < "$FINDINGS_FILE" + + # Get all non-archived repos in the org + local repos + repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 100) + + local repo_count=0 + + for repo in $repos; do + # Skip the .github config repo itself (different compliance criteria) + if [ "$repo" = ".github" ]; then + continue + fi + + repo_count=$((repo_count + 1)) + log "Auditing $ORG/$repo" + + detect_ecosystems "$repo" + info "Detected ecosystems: ${ECOSYSTEMS[*]:-none}" + + check_required_workflows "$repo" + check_action_pinning "$repo" + check_dependabot_config "$repo" + check_repo_settings "$repo" + check_labels "$repo" + check_rulesets "$repo" + check_codeowners "$repo" + check_sonarcloud "$repo" + check_workflow_permissions "$repo" + + log_end + done + + info "Audit complete: $findings_count findings across $repo_count repositories" + + # Generate summary report + generate_summary "$repo_count" + + # Create/update/close issues + if [ "$CREATE_ISSUES" = "true" ] && [ "$DRY_RUN" != "true" ]; then + info "Managing issues..." + + for repo in $repos; do + [ "$repo" = ".github" ] && continue + + ensure_audit_label "$repo" + + # Create issues for new findings + jq -c --arg repo "$repo" '.[] | select(.repo == $repo)' "$FINDINGS_FILE" | while read -r finding; do + local f_check f_severity f_detail f_standard_ref f_category + f_category=$(echo "$finding" | jq -r '.category') + f_check=$(echo "$finding" | jq -r '.check') + f_severity=$(echo "$finding" | jq -r '.severity') + f_detail=$(echo "$finding" | jq -r '.detail') + f_standard_ref=$(echo "$finding" | jq -r '.standard_ref') + + create_issue_for_finding "$repo" "$f_category" "$f_check" "$f_severity" "$f_detail" "$f_standard_ref" + done + + # Close issues for resolved findings + close_resolved_issues "$repo" + done + else + info "Skipping issue creation (DRY_RUN=$DRY_RUN, CREATE_ISSUES=$CREATE_ISSUES)" + fi + + # Output report paths + echo "findings=$FINDINGS_FILE" + echo "summary=$SUMMARY_FILE" + + info "Summary written to $SUMMARY_FILE" + info "Findings written to $FINDINGS_FILE" + + cat "$SUMMARY_FILE" +} + +main "$@" From 88101eba646737cbd683c30eb3e5f8a4cac1c473 Mon Sep 17 00:00:00 2001 From: DJ Date: Sun, 5 Apr 2026 10:37:16 -0700 Subject: [PATCH 2/4] fix: address review findings in compliance audit - Add retry error logging to gh_api helper - Fix pnpm detection when package.json absent - Fix empty ecosystem array display - Replace heredoc with direct assignment for issue body - Add jq error safety in close_resolved_issues - Increase repo list limit to 500 with empty check - Use process substitution instead of pipe subshell - Add concurrency group and timeout to workflow - Add timeout-minutes to audit job Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/compliance-audit.yml | 5 +++ scripts/compliance-audit.sh | 60 ++++++++++++++++---------- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/.github/workflows/compliance-audit.yml b/.github/workflows/compliance-audit.yml index c0cab31..60098ec 100644 --- a/.github/workflows/compliance-audit.yml +++ b/.github/workflows/compliance-audit.yml @@ -13,6 +13,10 @@ on: permissions: {} +concurrency: + group: compliance-audit + cancel-in-progress: false # Let running audits finish to avoid partial issue state + jobs: # ----------------------------------------------------------------------- # Job 1: Deterministic compliance checks @@ -23,6 +27,7 @@ jobs: audit: name: Compliance Audit runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read issues: write diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index d9806e5..6da354d 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -79,6 +79,8 @@ gh_api() { fi if [ "$i" -lt "$retries" ]; then sleep $((i * 2)) + else + info "gh api $1 failed after $retries retries" >&2 fi done return 1 @@ -99,8 +101,12 @@ detect_ecosystems() { ECOSYSTEMS+=("npm") fi if echo "$tree" | grep -qE '(^|/)pnpm-lock\.yaml$'; then - # Override npm with pnpm if lock file present - ECOSYSTEMS=("${ECOSYSTEMS[@]/npm/pnpm}") + # Override npm with pnpm if lock file present, or add pnpm directly + if [[ " ${ECOSYSTEMS[*]} " == *" npm "* ]]; then + ECOSYSTEMS=("${ECOSYSTEMS[@]/npm/pnpm}") + else + ECOSYSTEMS+=("pnpm") + fi fi if echo "$tree" | grep -qE '(^|/)go\.mod$'; then ECOSYSTEMS+=("go") @@ -430,33 +436,29 @@ This finding is still open. return fi - # Create new issue - local body - body=$(cat </dev/null); then + warn "Failed to read findings for $repo — skipping issue closure to avoid false positives" + return + fi while IFS=$'\t' read -r issue_num issue_title; do # Extract the check name from the title "Compliance: " @@ -600,7 +605,13 @@ main() { # Get all non-archived repos in the org local repos - repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 100) + repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) + + if [ -z "$repos" ]; then + warn "No repositories found in $ORG — check GH_TOKEN permissions" + echo "[]" > "$FINDINGS_FILE" + return 1 + fi local repo_count=0 @@ -614,7 +625,11 @@ main() { log "Auditing $ORG/$repo" detect_ecosystems "$repo" - info "Detected ecosystems: ${ECOSYSTEMS[*]:-none}" + if [ ${#ECOSYSTEMS[@]} -eq 0 ]; then + info "Detected ecosystems: none" + else + info "Detected ecosystems: ${ECOSYSTEMS[*]}" + fi check_required_workflows "$repo" check_action_pinning "$repo" @@ -643,8 +658,9 @@ main() { ensure_audit_label "$repo" - # Create issues for new findings - jq -c --arg repo "$repo" '.[] | select(.repo == $repo)' "$FINDINGS_FILE" | while read -r finding; do + # Create issues for new findings (process substitution avoids subshell) + while IFS= read -r finding; do + [ -z "$finding" ] && continue local f_check f_severity f_detail f_standard_ref f_category f_category=$(echo "$finding" | jq -r '.category') f_check=$(echo "$finding" | jq -r '.check') @@ -653,7 +669,7 @@ main() { f_standard_ref=$(echo "$finding" | jq -r '.standard_ref') create_issue_for_finding "$repo" "$f_category" "$f_check" "$f_severity" "$f_detail" "$f_standard_ref" - done + done < <(jq -c --arg repo "$repo" '.[] | select(.repo == $repo)' "$FINDINGS_FILE") # Close issues for resolved findings close_resolved_issues "$repo" From 7f659ec40ed5eb194839b4558cffdebe745f4c7a Mon Sep 17 00:00:00 2001 From: DJ Date: Sun, 5 Apr 2026 10:44:30 -0700 Subject: [PATCH 3/4] fix: address CodeRabbit and Copilot review comments - Handle single-job workflows with job-level permissions - Add has_issues to required settings checks - Soften CODEOWNERS wording (SHOULD not MUST per standards) - Remove misleading issues:write from audit job permissions - Rename repo_count to repos_with_findings for clarity Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/compliance-audit.yml | 9 ++++----- scripts/compliance-audit.sh | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/compliance-audit.yml b/.github/workflows/compliance-audit.yml index 60098ec..ca7bd59 100644 --- a/.github/workflows/compliance-audit.yml +++ b/.github/workflows/compliance-audit.yml @@ -30,14 +30,13 @@ jobs: timeout-minutes: 30 permissions: contents: read - issues: write env: GH_TOKEN: ${{ secrets.ORG_SCORECARD_TOKEN }} outputs: findings_count: ${{ steps.audit.outputs.findings_count }} error_count: ${{ steps.audit.outputs.error_count }} warning_count: ${{ steps.audit.outputs.warning_count }} - repo_count: ${{ steps.audit.outputs.repo_count }} + repos_with_findings: ${{ steps.audit.outputs.repos_with_findings }} steps: - name: Checkout .github repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -56,12 +55,12 @@ jobs: FINDINGS_COUNT=$(jq length "$REPORT_DIR/findings.json") ERROR_COUNT=$(jq '[.[] | select(.severity == "error")] | length' "$REPORT_DIR/findings.json") WARNING_COUNT=$(jq '[.[] | select(.severity == "warning")] | length' "$REPORT_DIR/findings.json") - REPO_COUNT=$(jq '[.[].repo] | unique | length' "$REPORT_DIR/findings.json") + REPOS_WITH_FINDINGS=$(jq '[.[].repo] | unique | length' "$REPORT_DIR/findings.json") echo "findings_count=$FINDINGS_COUNT" >> "$GITHUB_OUTPUT" echo "error_count=$ERROR_COUNT" >> "$GITHUB_OUTPUT" echo "warning_count=$WARNING_COUNT" >> "$GITHUB_OUTPUT" - echo "repo_count=$REPO_COUNT" >> "$GITHUB_OUTPUT" + echo "repos_with_findings=$REPOS_WITH_FINDINGS" >> "$GITHUB_OUTPUT" - name: Write step summary if: always() @@ -121,7 +120,7 @@ jobs: - Total findings: ${{ needs.audit.outputs.findings_count }} - Errors: ${{ needs.audit.outputs.error_count }} - Warnings: ${{ needs.audit.outputs.warning_count }} - - Repos with findings: ${{ needs.audit.outputs.repo_count }} + - Repos with findings: ${{ needs.audit.outputs.repos_with_findings }} - Findings JSON: ${{ runner.temp }}/compliance-report/findings.json - Summary report: ${{ runner.temp }}/compliance-report/summary.md - Workflow run: https://github.com/petry-projects/.github/actions/runs/${{ github.run_id }} diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index 6da354d..c5e52da 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -40,6 +40,7 @@ REQUIRED_SETTINGS_BOOL=( "allow_auto_merge:true:Allow auto-merge must be enabled for Dependabot workflow" "delete_branch_on_merge:true:Automatically delete head branches must be enabled" "has_wiki:false:Wiki should be disabled — documentation lives in the repo" + "has_issues:true:Issue tracking must be enabled" ) # --------------------------------------------------------------------------- @@ -342,7 +343,7 @@ check_codeowners() { if [ "$found" = false ]; then add_finding "$repo" "settings" "missing-codeowners" "warning" \ - "No \`CODEOWNERS\` file found — required for code owner review enforcement" \ + "No \`CODEOWNERS\` file found — recommended for code owner review enforcement" \ "standards/github-settings.md#pr-quality--standard-ruleset-all-repositories" fi } @@ -384,11 +385,18 @@ check_workflow_permissions() { [ -z "$decoded" ] && continue # Check if the workflow has a top-level permissions key - # A well-configured multi-job workflow resets permissions to {} at top level + # Single-job workflows may define permissions at job level instead if ! echo "$decoded" | grep -qE '^permissions:'; then - add_finding "$repo" "ci-workflows" "missing-permissions-$wf" "warning" \ - "Workflow \`$wf\` missing top-level \`permissions:\` declaration (least-privilege policy)" \ - "standards/ci-standards.md#permissions-policy" + # Count jobs and check if the single job has job-level permissions + local job_count + job_count=$(echo "$decoded" | grep -cE '^ [a-zA-Z_-]+:$' || echo "0") + local has_job_perms + has_job_perms=$(echo "$decoded" | grep -cE '^ permissions:' || echo "0") + if [ "$job_count" -gt 1 ] || [ "$has_job_perms" -eq 0 ]; then + add_finding "$repo" "ci-workflows" "missing-permissions-$wf" "warning" \ + "Workflow \`$wf\` missing top-level \`permissions:\` declaration (least-privilege policy)" \ + "standards/ci-standards.md#permissions-policy" + fi fi done } From 734db4540cdbc3f4fcab438bfc221d902363e30e Mon Sep 17 00:00:00 2001 From: DJ Date: Sun, 5 Apr 2026 10:56:57 -0700 Subject: [PATCH 4/4] fix: do not auto-close previous summary issues Per feedback, only humans should close summary/notification issues. Changed Claude prompt to explicitly not close them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/compliance-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compliance-audit.yml b/.github/workflows/compliance-audit.yml index ca7bd59..7c220c0 100644 --- a/.github/workflows/compliance-audit.yml +++ b/.github/workflows/compliance-audit.yml @@ -157,6 +157,6 @@ jobs: Include: executive summary, top priority items, workflow run link, any new standards improvement issues you created. Label: `compliance-audit`. - Close any previous "Weekly Compliance Audit Summary" issues first. + Do NOT close any previous summary issues — leave that to humans. allowed_tools: "Bash,Read,Glob,Grep" timeout_minutes: 20