|
|
@@ -0,0 +1,392 @@
|
|
|
+#!/usr/bin/env bash
|
|
|
+# Audit a GitHub repo's security posture — what's off, what's actually exposed.
|
|
|
+#
|
|
|
+# READ-ONLY. Only GET/HEAD `gh api` calls. The "enable" commands it prints are
|
|
|
+# emitted as TEXT for you to review and run yourself — this script NEVER applies
|
|
|
+# a change. It surfaces the blind spot: security features left off, and (where a
|
|
|
+# scanner is on) the OPEN findings that prove real exposure. Severity is
|
|
|
+# visibility-aware — a public repo gets free secret/push/code scanning, so a gap
|
|
|
+# there is a real finding; a private repo without Advanced Security gets those as
|
|
|
+# a NOTE ("needs GHAS"), not a nag.
|
|
|
+#
|
|
|
+# Usage: check-security-posture.sh [--repo OWNER/REPO | --remote NAME | --org OWNER]
|
|
|
+# [--commands] [--json] [--strict] [--advisory]
|
|
|
+# [-h|--help]
|
|
|
+# Input: argv only. Default repo = derived from the 'origin' remote of the cwd.
|
|
|
+# Output: stdout = data (human checklist, --commands enable list, or --json envelope).
|
|
|
+# --json schema: claude-mods.github-ops.security-posture/v1
|
|
|
+# Stderr: headers, the review banner, skip notices, errors.
|
|
|
+# Exit: 0 posture clean (all applicable features on, no open alerts)
|
|
|
+# 2 usage (bad/unknown flag, malformed OWNER/REPO)
|
|
|
+# 5 gh not installed (standalone; --advisory downgrades to a skip)
|
|
|
+# 7 unavailable — non-github remote, gh unauthed/offline, timeout
|
|
|
+# (ADVISORY signal; never a real failure)
|
|
|
+# 10 gaps and/or open alerts found (the thing to look at)
|
|
|
+#
|
|
|
+# Severity model (visibility-aware; documented so the mapping is auditable):
|
|
|
+# critical : open CRITICAL alerts present on an enabled scanner
|
|
|
+# high : open HIGH alerts; OR (public/active) push-protection off;
|
|
|
+# OR (public/active) Dependabot alerts off
|
|
|
+# medium : (public) secret-scanning or code-scanning off; Dependabot
|
|
|
+# security-updates off; no branch protection on the default branch
|
|
|
+# low : SECURITY.md absent; private vulnerability reporting off
|
|
|
+# note : feature needs paid GitHub Advanced Security on a private repo —
|
|
|
+# reported, but NOT counted as a gap (n/a unless GHAS is on)
|
|
|
+# By default low+medium gaps DO count toward exit 10 (they are real, free gaps).
|
|
|
+# --strict additionally makes any non-clean state exit 10 even in --advisory.
|
|
|
+# Free-on-any-repo features (Dependabot alerts, Dependabot security updates,
|
|
|
+# private vuln reporting, SECURITY.md) are always findings when off.
|
|
|
+#
|
|
|
+# Examples:
|
|
|
+# check-security-posture.sh --repo 0xDarkMatter/flarecrawl
|
|
|
+# check-security-posture.sh --remote origin
|
|
|
+# check-security-posture.sh --org 0xDarkMatter # fleet sweep
|
|
|
+# check-security-posture.sh --repo OWNER/REPO --commands # copy-paste enable cmds
|
|
|
+# check-security-posture.sh --repo OWNER/REPO --json | jq '.data[] | select(.state=="off")'
|
|
|
+set -uo pipefail
|
|
|
+
|
|
|
+EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
|
|
|
+GH_TIMEOUT="${GH_TIMEOUT:-20}" # seconds; bounds every network call
|
|
|
+
|
|
|
+REPO=""; REMOTE="origin"; ORG=""; COMMANDS=0; JSON=0; STRICT=0; ADVISORY=0
|
|
|
+while [ $# -gt 0 ]; do
|
|
|
+ case "$1" in
|
|
|
+ --repo) REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
|
|
|
+ --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
|
|
|
+ --org) ORG="${2:?--org needs an OWNER}"; shift 2 ;;
|
|
|
+ --commands) COMMANDS=1; shift ;;
|
|
|
+ --json) JSON=1; shift ;;
|
|
|
+ --strict) STRICT=1; shift ;;
|
|
|
+ --advisory) ADVISORY=1; shift ;;
|
|
|
+ -h|--help) sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
|
|
|
+ *) echo "check-security-posture: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
|
|
|
+ esac
|
|
|
+done
|
|
|
+
|
|
|
+# In advisory mode, any inability to check is a silent skip.
|
|
|
+skip() { # message
|
|
|
+ [ "$ADVISORY" -eq 1 ] || echo "check-security-posture: $1" >&2
|
|
|
+ exit "$EX_UNAVAILABLE"
|
|
|
+}
|
|
|
+
|
|
|
+command -v gh >/dev/null 2>&1 || {
|
|
|
+ [ "$ADVISORY" -eq 1 ] && exit "$EX_UNAVAILABLE"
|
|
|
+ echo "check-security-posture: gh not installed (https://cli.github.com)" >&2
|
|
|
+ exit "$EX_MISSING_DEP"
|
|
|
+}
|
|
|
+command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
|
+
|
|
|
+runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
|
|
|
+
|
|
|
+# Validate OWNER/REPO shape (agent safety — never interpolate a fabricated path).
|
|
|
+valid_repo() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; }
|
|
|
+valid_owner() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+$'; }
|
|
|
+
|
|
|
+# --------------------------------------------------------------------------
|
|
|
+# Per-repo audit. Emits one JSON object {repo, visibility, ghas, features:[...]}
|
|
|
+# to stdout via `printf`. Returns 0 clean / 10 findings / 7 unavailable.
|
|
|
+# Never crashes on a read error: unknown reads become state "unknown".
|
|
|
+# --------------------------------------------------------------------------
|
|
|
+audit_repo() { # OWNER/REPO -> echoes a JSON object, returns 0|10|7
|
|
|
+ local R="$1" core owner vis priv ghas ss ssp default_branch
|
|
|
+ owner="${R%%/*}"
|
|
|
+
|
|
|
+ core="$(runner gh api "repos/$R" 2>/dev/null)" || return 7
|
|
|
+ [ -n "$core" ] || return 7
|
|
|
+
|
|
|
+ vis="$(printf '%s' "$core" | jq -r '.visibility // (if .private then "private" else "public" end)')"
|
|
|
+ priv="$(printf '%s' "$core" | jq -r '.private')"
|
|
|
+ ghas="$(printf '%s' "$core" | jq -r '.security_and_analysis.advanced_security.status // "null"')"
|
|
|
+ ss="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning.status // "null"')"
|
|
|
+ ssp="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning_push_protection.status // "null"')"
|
|
|
+ default_branch="$(printf '%s' "$core" | jq -r '.default_branch // "main"')"
|
|
|
+
|
|
|
+ local is_public=0; [ "$vis" = "public" ] && is_public=1
|
|
|
+ local has_ghas=0; [ "$ghas" = "enabled" ] && has_ghas=1
|
|
|
+ # Secret/push/code scanning are "applicable" (a gap if off) when free: public repo,
|
|
|
+ # OR private repo with GHAS enabled. Otherwise they are a NOTE ("needs GHAS").
|
|
|
+ local scan_applicable=0
|
|
|
+ if [ "$is_public" -eq 1 ] || [ "$has_ghas" -eq 1 ]; then scan_applicable=1; fi
|
|
|
+
|
|
|
+ # Each feature row appended to this jq array as a compact object.
|
|
|
+ local features="[]"
|
|
|
+ add() { # feature state applicable severity enable_command [open_alerts] [max_severity]
|
|
|
+ features="$(jq -c \
|
|
|
+ --arg f "$1" --arg st "$2" --argjson ap "$3" --arg sev "$4" --arg cmd "$5" \
|
|
|
+ --arg oa "${6-}" --arg mx "${7-}" \
|
|
|
+ '. + [ ($oa|if .=="" then {} else {open_alerts: (.|tonumber)} end)
|
|
|
+ + ($mx|if .=="" then {} else {max_severity: .} end)
|
|
|
+ + {feature:$f, state:$st, applicable:$ap, severity:$sev, enable_command:$cmd} ]' \
|
|
|
+ <<<"$features")"
|
|
|
+ }
|
|
|
+
|
|
|
+ # ---- 1. Dependabot alerts (free on any repo) ----
|
|
|
+ local da_state da_cmd="gh api -X PUT repos/$R/vulnerability-alerts"
|
|
|
+ if runner gh api "repos/$R/vulnerability-alerts" --silent >/dev/null 2>&1; then
|
|
|
+ da_state="on"
|
|
|
+ else
|
|
|
+ # 404 = disabled (the normal case). A timeout/auth failure also lands here; we
|
|
|
+ # can't distinguish without the body, so treat as "off" but it'll be re-checked
|
|
|
+ # below only if on. Conservative: report off (never a false "on").
|
|
|
+ da_state="off"
|
|
|
+ fi
|
|
|
+ if [ "$da_state" = "on" ]; then
|
|
|
+ # Enabled -> fetch OPEN alerts for real exposure. 403/404 -> n/a couldn't read.
|
|
|
+ local da_json da_n da_max=""
|
|
|
+ da_json="$(runner gh api "repos/$R/dependabot/alerts?state=open&per_page=100" 2>/dev/null)"
|
|
|
+ if [ -n "$da_json" ] && printf '%s' "$da_json" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
|
|
+ da_n="$(printf '%s' "$da_json" | jq 'length')"
|
|
|
+ da_max="$(printf '%s' "$da_json" | jq -r '
|
|
|
+ ([.[].security_advisory.severity] | map(ascii_downcase)) as $s
|
|
|
+ | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
|
|
|
+ add "dependabot_alerts" "on" true "none" "$da_cmd" "$da_n" "$da_max"
|
|
|
+ else
|
|
|
+ add "dependabot_alerts" "on" true "none" "$da_cmd" "" "unknown"
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ add "dependabot_alerts" "off" true "$( [ "$is_public" -eq 1 ] && echo high || echo high )" "$da_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ---- 2. Dependabot security updates (free on any repo) ----
|
|
|
+ local asf asf_cmd="gh api -X PUT repos/$R/automated-security-fixes"
|
|
|
+ asf="$(runner gh api "repos/$R/automated-security-fixes" --jq '.enabled' 2>/dev/null | tr -d '\r')"
|
|
|
+ case "$asf" in
|
|
|
+ true) add "dependabot_security_updates" "on" true "none" "$asf_cmd" ;;
|
|
|
+ false) add "dependabot_security_updates" "off" true "medium" "$asf_cmd" ;;
|
|
|
+ *) add "dependabot_security_updates" "unknown" true "low" "$asf_cmd" ;;
|
|
|
+ esac
|
|
|
+
|
|
|
+ # ---- 3. Secret scanning (free on public; GHAS on private) ----
|
|
|
+ local ss_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"}}}'"'"
|
|
|
+ if [ "$scan_applicable" -eq 1 ]; then
|
|
|
+ if [ "$ss" = "enabled" ]; then
|
|
|
+ # On -> count open secret-scanning alerts. 403/404 -> couldn't read.
|
|
|
+ local sj sn
|
|
|
+ sj="$(runner gh api "repos/$R/secret-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
|
|
|
+ if [ -n "$sj" ] && printf '%s' "$sj" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
|
|
+ sn="$(printf '%s' "$sj" | jq 'length')"
|
|
|
+ # Any exposed secret is critical.
|
|
|
+ local sev=none; [ "$sn" -gt 0 ] && sev=critical
|
|
|
+ add "secret_scanning" "on" true "$sev" "$ss_cmd" "$sn"
|
|
|
+ else
|
|
|
+ add "secret_scanning" "on" true "none" "$ss_cmd" "" "unknown"
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ add "secret_scanning" "off" true "medium" "$ss_cmd"
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ add "secret_scanning" "n/a" false "note" "$ss_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ---- 4. Push protection (free on public; GHAS on private). Needs secret scanning first. ----
|
|
|
+ local pp_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"},"secret_scanning_push_protection":{"status":"enabled"}}}'"'"
|
|
|
+ if [ "$scan_applicable" -eq 1 ]; then
|
|
|
+ if [ "$ssp" = "enabled" ]; then
|
|
|
+ add "secret_scanning_push_protection" "on" true "none" "$pp_cmd"
|
|
|
+ else
|
|
|
+ add "secret_scanning_push_protection" "off" true "high" "$pp_cmd"
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ add "secret_scanning_push_protection" "n/a" false "note" "$pp_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ---- 5. Code scanning default setup (free on public; GHAS on private) ----
|
|
|
+ local cs_state cs_cmd="gh api -X PUT repos/$R/code-scanning/default-setup -f state=configured"
|
|
|
+ cs_state="$(runner gh api "repos/$R/code-scanning/default-setup" --jq '.state' 2>/dev/null | tr -d '\r')"
|
|
|
+ if [ "$scan_applicable" -eq 1 ]; then
|
|
|
+ if [ "$cs_state" = "configured" ]; then
|
|
|
+ local cj cn cmax=""
|
|
|
+ cj="$(runner gh api "repos/$R/code-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
|
|
|
+ if [ -n "$cj" ] && printf '%s' "$cj" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
|
|
+ cn="$(printf '%s' "$cj" | jq 'length')"
|
|
|
+ cmax="$(printf '%s' "$cj" | jq -r '
|
|
|
+ ([.[].rule.security_severity_level // .[].rule.severity // empty] | map(ascii_downcase)) as $s
|
|
|
+ | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
|
|
|
+ add "code_scanning" "on" true "none" "$cs_cmd" "$cn" "$cmax"
|
|
|
+ else
|
|
|
+ add "code_scanning" "on" true "none" "$cs_cmd" "" "unknown"
|
|
|
+ fi
|
|
|
+ elif [ -n "$cs_state" ] && [ "$cs_state" != "null" ]; then
|
|
|
+ add "code_scanning" "off" true "medium" "$cs_cmd" # not-configured
|
|
|
+ else
|
|
|
+ add "code_scanning" "unknown" true "low" "$cs_cmd" # couldn't read
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ add "code_scanning" "n/a" false "note" "$cs_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ---- 6. Private vulnerability reporting (free on any repo) ----
|
|
|
+ local pvr pvr_cmd="gh api -X PUT repos/$R/private-vulnerability-reporting"
|
|
|
+ pvr="$(runner gh api "repos/$R/private-vulnerability-reporting" --jq '.enabled' 2>/dev/null | tr -d '\r')"
|
|
|
+ case "$pvr" in
|
|
|
+ true) add "private_vulnerability_reporting" "on" true "none" "$pvr_cmd" ;;
|
|
|
+ false) add "private_vulnerability_reporting" "off" true "low" "$pvr_cmd" ;;
|
|
|
+ *) add "private_vulnerability_reporting" "unknown" true "low" "$pvr_cmd" ;;
|
|
|
+ esac
|
|
|
+
|
|
|
+ # ---- 7. SECURITY.md present (root, .github/, docs/) ----
|
|
|
+ local sec_found=0 loc
|
|
|
+ for loc in "SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md"; do
|
|
|
+ if runner gh api "repos/$R/contents/$loc" --silent >/dev/null 2>&1; then sec_found=1; break; fi
|
|
|
+ done
|
|
|
+ local sec_cmd="cp assets/SECURITY.md.template SECURITY.md # edit, commit, push"
|
|
|
+ if [ "$sec_found" -eq 1 ]; then
|
|
|
+ add "security_policy" "on" true "none" "$sec_cmd"
|
|
|
+ else
|
|
|
+ add "security_policy" "off" true "low" "$sec_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ---- 8. Branch protection on the default branch (bonus) ----
|
|
|
+ local bp_cmd="# branch protection: see github.com/$R/settings/branches (requires a ruleset/protection JSON)"
|
|
|
+ if runner gh api "repos/$R/branches/$default_branch/protection" --silent >/dev/null 2>&1; then
|
|
|
+ add "branch_protection" "on" true "none" "$bp_cmd"
|
|
|
+ else
|
|
|
+ # 404 not-protected / 403 no-access -> treat as off (free to set on any repo).
|
|
|
+ add "branch_protection" "off" true "medium" "$bp_cmd"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # Assemble the repo object and decide the per-repo exit.
|
|
|
+ local obj
|
|
|
+ obj="$(jq -c -n --arg repo "$R" --arg vis "$vis" --argjson priv "${priv:-false}" \
|
|
|
+ --arg ghas "$ghas" --argjson feat "$features" \
|
|
|
+ '{repo:$repo, visibility:$vis, private:$priv,
|
|
|
+ ghas:(if $ghas=="null" then null else $ghas end), features:$feat}')"
|
|
|
+ printf '%s' "$obj"
|
|
|
+
|
|
|
+ # Findings = any applicable feature that is off/unknown, OR any open_alerts>0.
|
|
|
+ local gaps
|
|
|
+ gaps="$(printf '%s' "$obj" | jq '
|
|
|
+ [ .features[]
|
|
|
+ | select(.applicable == true)
|
|
|
+ | select( (.state=="off") or (.state=="unknown") or ((.open_alerts // 0) > 0) )
|
|
|
+ ] | length')"
|
|
|
+ [ "$gaps" -gt 0 ] && return 10
|
|
|
+ return 0
|
|
|
+}
|
|
|
+
|
|
|
+# Severity glyph helper for human output.
|
|
|
+sev_tag() { case "$1" in
|
|
|
+ critical) printf '[critical]';; high) printf '[high]';;
|
|
|
+ medium) printf '[medium]';; low) printf '[low]';;
|
|
|
+ note) printf '';; *) printf '';; esac; }
|
|
|
+
|
|
|
+# Human checklist for one repo object (reads JSON on stdin-arg $1).
|
|
|
+print_human() { # repo_json
|
|
|
+ local o="$1" repo vis
|
|
|
+ repo="$(printf '%s' "$o" | jq -r '.repo')"
|
|
|
+ vis="$(printf '%s' "$o" | jq -r '.visibility')"
|
|
|
+ {
|
|
|
+ echo "SECURITY POSTURE — $repo ($vis)"
|
|
|
+ printf '%s' "$o" | jq -r '
|
|
|
+ .features[] |
|
|
|
+ if .state=="on" then
|
|
|
+ " ✓ \(.feature)" +
|
|
|
+ (if (.open_alerts // 0) > 0 then " — \(.open_alerts) OPEN alert(s)" + (if .max_severity then ", max \(.max_severity)" else "" end) else "" end) +
|
|
|
+ (if .max_severity=="unknown" then " (alerts: couldn’t read — needs security_events scope)" else "" end)
|
|
|
+ elif .state=="n/a" then
|
|
|
+ " — \(.feature) n/a (needs GitHub Advanced Security on a private repo)"
|
|
|
+ elif .state=="unknown" then
|
|
|
+ " ? \(.feature) n/a (couldn’t read)"
|
|
|
+ else
|
|
|
+ " ✗ \(.feature) [\(.severity)]"
|
|
|
+ end'
|
|
|
+ # Enable commands for gaps.
|
|
|
+ local has_gap
|
|
|
+ has_gap="$(printf '%s' "$o" | jq '[.features[]|select(.applicable==true and (.state=="off"))]|length')"
|
|
|
+ if [ "$has_gap" -gt 0 ]; then
|
|
|
+ echo " ── enable commands (review before running; this script never runs them):"
|
|
|
+ printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|" \(.enable_command)"'
|
|
|
+ fi
|
|
|
+ } >&2
|
|
|
+}
|
|
|
+
|
|
|
+# Emit ONLY the enable commands (data on stdout; banner on stderr).
|
|
|
+print_commands() { # repo_json
|
|
|
+ local o="$1"
|
|
|
+ echo "# review before running — these change repo settings" >&2
|
|
|
+ printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|.enable_command'
|
|
|
+}
|
|
|
+
|
|
|
+# ==========================================================================
|
|
|
+# Mode dispatch
|
|
|
+# ==========================================================================
|
|
|
+
|
|
|
+# Conflicting selectors.
|
|
|
+sel=0
|
|
|
+[ -n "$REPO" ] && sel=$((sel+1))
|
|
|
+[ -n "$ORG" ] && sel=$((sel+1))
|
|
|
+if [ "$sel" -gt 1 ]; then
|
|
|
+ echo "check-security-posture: --repo and --org are mutually exclusive" >&2; exit "$EX_USAGE"
|
|
|
+fi
|
|
|
+
|
|
|
+# ---- Fleet sweep ----
|
|
|
+if [ -n "$ORG" ]; then
|
|
|
+ valid_owner "$ORG" || { echo "check-security-posture: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
|
|
|
+ list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner 2>/dev/null)" \
|
|
|
+ || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
|
|
|
+ [ -n "$list" ] || skip "no repos returned for $ORG"
|
|
|
+ mapfile -t repos < <(printf '%s' "$list" | jq -r '.[].nameWithOwner' | tr -d '\r')
|
|
|
+ [ "${#repos[@]}" -gt 0 ] || skip "no non-archived repos for $ORG"
|
|
|
+
|
|
|
+ all="[]"; any_findings=0; swept=0; unread=0
|
|
|
+ for r in "${repos[@]}"; do
|
|
|
+ valid_repo "$r" || continue
|
|
|
+ obj="$(audit_repo "$r")"; rc=$?
|
|
|
+ if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then
|
|
|
+ unread=$((unread+1))
|
|
|
+ [ "$JSON" -eq 1 ] || echo " ? $r — couldn't read (skipped)" >&2
|
|
|
+ continue
|
|
|
+ fi
|
|
|
+ swept=$((swept+1))
|
|
|
+ [ "$rc" -eq 10 ] && any_findings=1
|
|
|
+ all="$(jq -c --argjson o "$obj" '. + [$o]' <<<"$all")"
|
|
|
+ if [ "$JSON" -eq 0 ] && [ "$COMMANDS" -eq 0 ]; then
|
|
|
+ gaps="$(printf '%s' "$obj" | jq '[.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown") or ((.open_alerts//0)>0)))]|length')"
|
|
|
+ vis="$(printf '%s' "$obj" | jq -r '.visibility')"
|
|
|
+ if [ "$gaps" -eq 0 ]; then echo " ✓ $r ($vis) — clean" >&2
|
|
|
+ else echo " ✗ $r ($vis) — $gaps gap(s)/alert(s)" >&2; fi
|
|
|
+ fi
|
|
|
+ done
|
|
|
+
|
|
|
+ if [ "$JSON" -eq 1 ]; then
|
|
|
+ jq -c -n --argjson data "$all" --arg org "$ORG" \
|
|
|
+ --argjson swept "$swept" --argjson unread "$unread" --argjson find "$any_findings" \
|
|
|
+ '{data:$data, meta:{org:$org, repos_audited:$swept, repos_unreadable:$unread, findings:($find==1), schema:"claude-mods.github-ops.security-posture/v1"}}'
|
|
|
+ elif [ "$COMMANDS" -eq 1 ]; then
|
|
|
+ echo "# review before running — these change repo settings" >&2
|
|
|
+ printf '%s' "$all" | jq -r '.[] | "# \(.repo)", (.features[]|select(.applicable==true and .state=="off")|" \(.enable_command)")'
|
|
|
+ else
|
|
|
+ echo "── swept $swept repo(s) in $ORG; $unread unreadable. ✗ = action available." >&2
|
|
|
+ fi
|
|
|
+ [ "$any_findings" -eq 1 ] && exit "$EX_FINDINGS"
|
|
|
+ exit "$EX_OK"
|
|
|
+fi
|
|
|
+
|
|
|
+# ---- Single repo ----
|
|
|
+if [ -z "$REPO" ]; then
|
|
|
+ url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
|
|
|
+ case "$url" in
|
|
|
+ *github.com[:/]*)
|
|
|
+ REPO="$(printf '%s' "$url" | tr -d '\r' | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
|
|
|
+ *) skip "remote '$REMOTE' is not a github.com repo" ;;
|
|
|
+ esac
|
|
|
+fi
|
|
|
+valid_repo "$REPO" || { echo "check-security-posture: invalid OWNER/REPO '$REPO'" >&2; exit "$EX_USAGE"; }
|
|
|
+
|
|
|
+obj="$(audit_repo "$REPO")"; rc=$?
|
|
|
+if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then skip "couldn't read $REPO (not authed / offline / not found / timeout)"; fi
|
|
|
+
|
|
|
+if [ "$JSON" -eq 1 ]; then
|
|
|
+ printf '%s' "$obj" | jq -c \
|
|
|
+ '{data: .features, meta: {repo:.repo, visibility:.visibility, private:.private, ghas:.ghas,
|
|
|
+ gaps: ([.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown")))]|length),
|
|
|
+ open_alerts: ([.features[].open_alerts // 0]|add),
|
|
|
+ schema:"claude-mods.github-ops.security-posture/v1"}}'
|
|
|
+elif [ "$COMMANDS" -eq 1 ]; then
|
|
|
+ print_commands "$obj"
|
|
|
+else
|
|
|
+ print_human "$obj"
|
|
|
+fi
|
|
|
+
|
|
|
+[ "$rc" -eq 10 ] && exit "$EX_FINDINGS"
|
|
|
+exit "$EX_OK"
|