check-security-posture.sh 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. #!/usr/bin/env bash
  2. # Audit a GitHub repo's security posture — what's off, what's actually exposed.
  3. #
  4. # READ-ONLY. Only GET/HEAD `gh api` calls. The "enable" commands it prints are
  5. # emitted as TEXT for you to review and run yourself — this script NEVER applies
  6. # a change. It surfaces the blind spot: security features left off, and (where a
  7. # scanner is on) the OPEN findings that prove real exposure. Severity is
  8. # visibility-aware — a public repo gets free secret/push/code scanning, so a gap
  9. # there is a real finding; a private repo without Advanced Security gets those as
  10. # a NOTE ("needs GHAS"), not a nag.
  11. #
  12. # Usage: check-security-posture.sh [--repo OWNER/REPO | --remote NAME | --org OWNER]
  13. # [--commands] [--json] [--strict] [--advisory]
  14. # [-h|--help]
  15. # Input: argv only. Default repo = derived from the 'origin' remote of the cwd.
  16. # Output: stdout = data (human checklist, --commands enable list, or --json envelope).
  17. # --json schema: claude-mods.github-ops.security-posture/v1
  18. # Stderr: headers, the review banner, skip notices, errors.
  19. # Exit: 0 posture clean (all applicable features on, no open alerts)
  20. # 2 usage (bad/unknown flag, malformed OWNER/REPO)
  21. # 5 gh not installed (standalone; --advisory downgrades to a skip)
  22. # 7 unavailable — non-github remote, gh unauthed/offline, timeout
  23. # (ADVISORY signal; never a real failure)
  24. # 10 gaps and/or open alerts found (the thing to look at)
  25. #
  26. # Severity model (visibility-aware; documented so the mapping is auditable):
  27. # critical : open CRITICAL alerts present on an enabled scanner
  28. # high : open HIGH alerts; OR (public/active) push-protection off;
  29. # OR (public/active) Dependabot alerts off
  30. # medium : (public) secret-scanning or code-scanning off; Dependabot
  31. # security-updates off; no branch protection on the default branch
  32. # low : SECURITY.md absent; private vulnerability reporting off
  33. # note : feature needs paid GitHub Advanced Security on a private repo —
  34. # reported, but NOT counted as a gap (n/a unless GHAS is on)
  35. # By default low+medium gaps DO count toward exit 10 (they are real, free gaps).
  36. # --strict additionally makes any non-clean state exit 10 even in --advisory.
  37. # Free-on-any-repo features (Dependabot alerts, Dependabot security updates,
  38. # private vuln reporting, SECURITY.md) are always findings when off.
  39. #
  40. # Examples:
  41. # check-security-posture.sh --repo 0xDarkMatter/flarecrawl
  42. # check-security-posture.sh --remote origin
  43. # check-security-posture.sh --org 0xDarkMatter # fleet sweep
  44. # check-security-posture.sh --repo OWNER/REPO --commands # copy-paste enable cmds
  45. # check-security-posture.sh --repo OWNER/REPO --json | jq '.data[] | select(.state=="off")'
  46. set -uo pipefail
  47. EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
  48. GH_TIMEOUT="${GH_TIMEOUT:-20}" # seconds; bounds every network call
  49. # Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
  50. # color on fd 2. Degrade to plain output if the shared lib isn't reachable.
  51. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  52. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  53. else
  54. term_panel_open() { printf '== %s %s ==\n' "${2:-}" "${3:-}"; }
  55. term_panel_close() { [ -n "${1:-}" ] && printf '%s\n' "$1"; }
  56. term_panel_vert() { :; }
  57. term_panel_line() { printf ' %s\n' "$*"; }
  58. term_section() { printf '%s (%s)\n' "${2:-}" "${3:-}"; }
  59. term_color() { shift; printf '%s' "$*"; }
  60. term_mark() { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
  61. term_health() { shift; printf '%s' "$*"; }
  62. fi
  63. REPO=""; REMOTE="origin"; ORG=""; COMMANDS=0; JSON=0; STRICT=0; ADVISORY=0
  64. while [ $# -gt 0 ]; do
  65. case "$1" in
  66. --repo) REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
  67. --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
  68. --org) ORG="${2:?--org needs an OWNER}"; shift 2 ;;
  69. --commands) COMMANDS=1; shift ;;
  70. --json) JSON=1; shift ;;
  71. --strict) STRICT=1; shift ;;
  72. --advisory) ADVISORY=1; shift ;;
  73. -h|--help) sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
  74. *) echo "check-security-posture: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
  75. esac
  76. done
  77. # In advisory mode, any inability to check is a silent skip.
  78. skip() { # message
  79. [ "$ADVISORY" -eq 1 ] || echo "check-security-posture: $1" >&2
  80. exit "$EX_UNAVAILABLE"
  81. }
  82. command -v gh >/dev/null 2>&1 || {
  83. [ "$ADVISORY" -eq 1 ] && exit "$EX_UNAVAILABLE"
  84. echo "check-security-posture: gh not installed (https://cli.github.com)" >&2
  85. exit "$EX_MISSING_DEP"
  86. }
  87. command -v jq >/dev/null 2>&1 || skip "jq not installed"
  88. runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
  89. # Validate OWNER/REPO shape (agent safety — never interpolate a fabricated path).
  90. valid_repo() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; }
  91. valid_owner() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+$'; }
  92. # --------------------------------------------------------------------------
  93. # Per-repo audit. Emits one JSON object {repo, visibility, ghas, features:[...]}
  94. # to stdout via `printf`. Returns 0 clean / 10 findings / 7 unavailable.
  95. # Never crashes on a read error: unknown reads become state "unknown".
  96. # --------------------------------------------------------------------------
  97. audit_repo() { # OWNER/REPO -> echoes a JSON object, returns 0|10|7
  98. local R="$1" core owner vis priv ghas ss ssp default_branch
  99. owner="${R%%/*}"
  100. core="$(runner gh api "repos/$R" 2>/dev/null)" || return 7
  101. [ -n "$core" ] || return 7
  102. vis="$(printf '%s' "$core" | jq -r '.visibility // (if .private then "private" else "public" end)')"
  103. priv="$(printf '%s' "$core" | jq -r '.private')"
  104. ghas="$(printf '%s' "$core" | jq -r '.security_and_analysis.advanced_security.status // "null"')"
  105. ss="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning.status // "null"')"
  106. ssp="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning_push_protection.status // "null"')"
  107. default_branch="$(printf '%s' "$core" | jq -r '.default_branch // "main"')"
  108. local is_public=0; [ "$vis" = "public" ] && is_public=1
  109. local has_ghas=0; [ "$ghas" = "enabled" ] && has_ghas=1
  110. # Secret/push/code scanning are "applicable" (a gap if off) when free: public repo,
  111. # OR private repo with GHAS enabled. Otherwise they are a NOTE ("needs GHAS").
  112. local scan_applicable=0
  113. if [ "$is_public" -eq 1 ] || [ "$has_ghas" -eq 1 ]; then scan_applicable=1; fi
  114. # Each feature row appended to this jq array as a compact object.
  115. local features="[]"
  116. add() { # feature state applicable severity enable_command [open_alerts] [max_severity]
  117. features="$(jq -c \
  118. --arg f "$1" --arg st "$2" --argjson ap "$3" --arg sev "$4" --arg cmd "$5" \
  119. --arg oa "${6-}" --arg mx "${7-}" \
  120. '. + [ ($oa|if .=="" then {} else {open_alerts: (.|tonumber)} end)
  121. + ($mx|if .=="" then {} else {max_severity: .} end)
  122. + {feature:$f, state:$st, applicable:$ap, severity:$sev, enable_command:$cmd} ]' \
  123. <<<"$features")"
  124. }
  125. # ---- 1. Dependabot alerts (free on any repo) ----
  126. local da_state da_cmd="gh api -X PUT repos/$R/vulnerability-alerts"
  127. if runner gh api "repos/$R/vulnerability-alerts" --silent >/dev/null 2>&1; then
  128. da_state="on"
  129. else
  130. # 404 = disabled (the normal case). A timeout/auth failure also lands here; we
  131. # can't distinguish without the body, so treat as "off" but it'll be re-checked
  132. # below only if on. Conservative: report off (never a false "on").
  133. da_state="off"
  134. fi
  135. if [ "$da_state" = "on" ]; then
  136. # Enabled -> fetch OPEN alerts for real exposure. 403/404 -> n/a couldn't read.
  137. local da_json da_n da_max=""
  138. da_json="$(runner gh api "repos/$R/dependabot/alerts?state=open&per_page=100" 2>/dev/null)"
  139. if [ -n "$da_json" ] && printf '%s' "$da_json" | jq -e 'type=="array"' >/dev/null 2>&1; then
  140. da_n="$(printf '%s' "$da_json" | jq 'length')"
  141. da_max="$(printf '%s' "$da_json" | jq -r '
  142. ([.[].security_advisory.severity] | map(ascii_downcase)) as $s
  143. | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
  144. add "dependabot_alerts" "on" true "none" "$da_cmd" "$da_n" "$da_max"
  145. else
  146. add "dependabot_alerts" "on" true "none" "$da_cmd" "" "unknown"
  147. fi
  148. else
  149. add "dependabot_alerts" "off" true "$( [ "$is_public" -eq 1 ] && echo high || echo high )" "$da_cmd"
  150. fi
  151. # ---- 2. Dependabot security updates (free on any repo) ----
  152. local asf asf_cmd="gh api -X PUT repos/$R/automated-security-fixes"
  153. asf="$(runner gh api "repos/$R/automated-security-fixes" --jq '.enabled' 2>/dev/null | tr -d '\r')"
  154. case "$asf" in
  155. true) add "dependabot_security_updates" "on" true "none" "$asf_cmd" ;;
  156. false) add "dependabot_security_updates" "off" true "medium" "$asf_cmd" ;;
  157. *) add "dependabot_security_updates" "unknown" true "low" "$asf_cmd" ;;
  158. esac
  159. # ---- 3. Secret scanning (free on public; GHAS on private) ----
  160. local ss_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"}}}'"'"
  161. if [ "$scan_applicable" -eq 1 ]; then
  162. if [ "$ss" = "enabled" ]; then
  163. # On -> count open secret-scanning alerts. 403/404 -> couldn't read.
  164. local sj sn
  165. sj="$(runner gh api "repos/$R/secret-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
  166. if [ -n "$sj" ] && printf '%s' "$sj" | jq -e 'type=="array"' >/dev/null 2>&1; then
  167. sn="$(printf '%s' "$sj" | jq 'length')"
  168. # Any exposed secret is critical.
  169. local sev=none; [ "$sn" -gt 0 ] && sev=critical
  170. add "secret_scanning" "on" true "$sev" "$ss_cmd" "$sn"
  171. else
  172. add "secret_scanning" "on" true "none" "$ss_cmd" "" "unknown"
  173. fi
  174. else
  175. add "secret_scanning" "off" true "medium" "$ss_cmd"
  176. fi
  177. else
  178. add "secret_scanning" "n/a" false "note" "$ss_cmd"
  179. fi
  180. # ---- 4. Push protection (free on public; GHAS on private). Needs secret scanning first. ----
  181. local pp_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"},"secret_scanning_push_protection":{"status":"enabled"}}}'"'"
  182. if [ "$scan_applicable" -eq 1 ]; then
  183. if [ "$ssp" = "enabled" ]; then
  184. add "secret_scanning_push_protection" "on" true "none" "$pp_cmd"
  185. else
  186. add "secret_scanning_push_protection" "off" true "high" "$pp_cmd"
  187. fi
  188. else
  189. add "secret_scanning_push_protection" "n/a" false "note" "$pp_cmd"
  190. fi
  191. # ---- 5. Code scanning default setup (free on public; GHAS on private) ----
  192. local cs_state cs_cmd="gh api -X PUT repos/$R/code-scanning/default-setup -f state=configured"
  193. cs_state="$(runner gh api "repos/$R/code-scanning/default-setup" --jq '.state' 2>/dev/null | tr -d '\r')"
  194. if [ "$scan_applicable" -eq 1 ]; then
  195. if [ "$cs_state" = "configured" ]; then
  196. local cj cn cmax=""
  197. cj="$(runner gh api "repos/$R/code-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
  198. if [ -n "$cj" ] && printf '%s' "$cj" | jq -e 'type=="array"' >/dev/null 2>&1; then
  199. cn="$(printf '%s' "$cj" | jq 'length')"
  200. cmax="$(printf '%s' "$cj" | jq -r '
  201. ([.[].rule.security_severity_level // .[].rule.severity // empty] | map(ascii_downcase)) as $s
  202. | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
  203. add "code_scanning" "on" true "none" "$cs_cmd" "$cn" "$cmax"
  204. else
  205. add "code_scanning" "on" true "none" "$cs_cmd" "" "unknown"
  206. fi
  207. elif [ -n "$cs_state" ] && [ "$cs_state" != "null" ]; then
  208. add "code_scanning" "off" true "medium" "$cs_cmd" # not-configured
  209. else
  210. add "code_scanning" "unknown" true "low" "$cs_cmd" # couldn't read
  211. fi
  212. else
  213. add "code_scanning" "n/a" false "note" "$cs_cmd"
  214. fi
  215. # ---- 6. Private vulnerability reporting (free on any repo) ----
  216. local pvr pvr_cmd="gh api -X PUT repos/$R/private-vulnerability-reporting"
  217. pvr="$(runner gh api "repos/$R/private-vulnerability-reporting" --jq '.enabled' 2>/dev/null | tr -d '\r')"
  218. case "$pvr" in
  219. true) add "private_vulnerability_reporting" "on" true "none" "$pvr_cmd" ;;
  220. false) add "private_vulnerability_reporting" "off" true "low" "$pvr_cmd" ;;
  221. *) add "private_vulnerability_reporting" "unknown" true "low" "$pvr_cmd" ;;
  222. esac
  223. # ---- 7. SECURITY.md present (root, .github/, docs/) ----
  224. local sec_found=0 loc
  225. for loc in "SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md"; do
  226. if runner gh api "repos/$R/contents/$loc" --silent >/dev/null 2>&1; then sec_found=1; break; fi
  227. done
  228. local sec_cmd="cp assets/SECURITY.md.template SECURITY.md # edit, commit, push"
  229. if [ "$sec_found" -eq 1 ]; then
  230. add "security_policy" "on" true "none" "$sec_cmd"
  231. else
  232. add "security_policy" "off" true "low" "$sec_cmd"
  233. fi
  234. # ---- 8. Branch protection on the default branch (bonus) ----
  235. local bp_cmd="# branch protection: see github.com/$R/settings/branches (requires a ruleset/protection JSON)"
  236. if runner gh api "repos/$R/branches/$default_branch/protection" --silent >/dev/null 2>&1; then
  237. add "branch_protection" "on" true "none" "$bp_cmd"
  238. else
  239. # 404 not-protected / 403 no-access -> treat as off (free to set on any repo).
  240. add "branch_protection" "off" true "medium" "$bp_cmd"
  241. fi
  242. # Assemble the repo object and decide the per-repo exit.
  243. local obj
  244. obj="$(jq -c -n --arg repo "$R" --arg vis "$vis" --argjson priv "${priv:-false}" \
  245. --arg ghas "$ghas" --argjson feat "$features" \
  246. '{repo:$repo, visibility:$vis, private:$priv,
  247. ghas:(if $ghas=="null" then null else $ghas end), features:$feat}')"
  248. printf '%s' "$obj"
  249. # Findings = any applicable feature that is off/unknown, OR any open_alerts>0.
  250. local gaps
  251. gaps="$(printf '%s' "$obj" | jq '
  252. [ .features[]
  253. | select(.applicable == true)
  254. | select( (.state=="off") or (.state=="unknown") or ((.open_alerts // 0) > 0) )
  255. ] | length')"
  256. [ "$gaps" -gt 0 ] && return 10
  257. return 0
  258. }
  259. # Severity glyph helper for human output.
  260. sev_tag() { case "$1" in
  261. critical) printf '[critical]';; high) printf '[high]';;
  262. medium) printf '[medium]';; low) printf '[low]';;
  263. note) printf '';; *) printf '';; esac; }
  264. # Human checklist for one repo object (reads JSON on stdin-arg $1).
  265. print_human() { # repo_json
  266. local o="$1" repo vis
  267. repo="$(printf '%s' "$o" | jq -r '.repo')"
  268. vis="$(printf '%s' "$o" | jq -r '.visibility')"
  269. local hgaps health
  270. hgaps="$(printf '%s' "$o" | jq '[.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown") or ((.open_alerts//0)>0)))]|length')"
  271. if [ "$hgaps" -gt 0 ]; then health="$(term_health warning "$hgaps gap(s)/alert(s)")"; else health="$(term_health healthy clean)"; fi
  272. {
  273. term_panel_open github-ops "SECURITY POSTURE" "$repo $vis"
  274. term_panel_vert
  275. while IFS= read -r ln; do term_panel_line "$ln"; done < <(printf '%s' "$o" | jq -r \
  276. --arg ok "$(term_mark ok)" --arg bad "$(term_mark bad)" \
  277. --arg na "$(term_mark na)" --arg unk "$(term_mark unknown)" '
  278. .features[] |
  279. if .state=="on" then
  280. "\($ok) \(.feature)" +
  281. (if (.open_alerts // 0) > 0 then " — \(.open_alerts) OPEN alert(s)" + (if .max_severity then ", max \(.max_severity)" else "" end) else "" end) +
  282. (if .max_severity=="unknown" then " (alerts: couldn’t read — needs security_events scope)" else "" end)
  283. elif .state=="n/a" then
  284. "\($na) \(.feature) n/a (needs GitHub Advanced Security on a private repo)"
  285. elif .state=="unknown" then
  286. "\($unk) \(.feature) n/a (couldn’t read)"
  287. else
  288. "\($bad) \(.feature) [\(.severity)]"
  289. end')
  290. # Enable commands for gaps.
  291. local has_gap
  292. has_gap="$(printf '%s' "$o" | jq '[.features[]|select(.applicable==true and (.state=="off"))]|length')"
  293. if [ "$has_gap" -gt 0 ]; then
  294. term_panel_vert
  295. term_section "" "enable commands" "$has_gap"
  296. while IFS= read -r ln; do term_panel_line "$(term_color dim "$ln")"; done < <(printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|.enable_command')
  297. fi
  298. term_panel_vert
  299. term_panel_close "$(term_color dim "review before running this script never runs them")" "$health"
  300. } >&2
  301. }
  302. # Emit ONLY the enable commands (data on stdout; banner on stderr).
  303. print_commands() { # repo_json
  304. local o="$1"
  305. echo "# review before running — these change repo settings" >&2
  306. printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|.enable_command'
  307. }
  308. # ==========================================================================
  309. # Mode dispatch
  310. # ==========================================================================
  311. # Conflicting selectors.
  312. sel=0
  313. [ -n "$REPO" ] && sel=$((sel+1))
  314. [ -n "$ORG" ] && sel=$((sel+1))
  315. if [ "$sel" -gt 1 ]; then
  316. echo "check-security-posture: --repo and --org are mutually exclusive" >&2; exit "$EX_USAGE"
  317. fi
  318. # ---- Fleet sweep ----
  319. if [ -n "$ORG" ]; then
  320. valid_owner "$ORG" || { echo "check-security-posture: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
  321. list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner 2>/dev/null)" \
  322. || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
  323. [ -n "$list" ] || skip "no repos returned for $ORG"
  324. mapfile -t repos < <(printf '%s' "$list" | jq -r '.[].nameWithOwner' | tr -d '\r')
  325. [ "${#repos[@]}" -gt 0 ] || skip "no non-archived repos for $ORG"
  326. human=0; [ "$JSON" -eq 0 ] && [ "$COMMANDS" -eq 0 ] && human=1
  327. [ "$human" -eq 1 ] && { term_panel_open github-ops "SECURITY POSTURE" "$ORG fleet sweep" >&2; term_panel_vert >&2; }
  328. all="[]"; any_findings=0; swept=0; unread=0
  329. for r in "${repos[@]}"; do
  330. valid_repo "$r" || continue
  331. obj="$(audit_repo "$r")"; rc=$?
  332. if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then
  333. unread=$((unread+1))
  334. [ "$human" -eq 1 ] && term_panel_line "$(term_mark unknown) $r — couldn't read (skipped)" >&2
  335. continue
  336. fi
  337. swept=$((swept+1))
  338. [ "$rc" -eq 10 ] && any_findings=1
  339. all="$(jq -c --argjson o "$obj" '. + [$o]' <<<"$all")"
  340. if [ "$human" -eq 1 ]; then
  341. gaps="$(printf '%s' "$obj" | jq '[.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown") or ((.open_alerts//0)>0)))]|length')"
  342. vis="$(printf '%s' "$obj" | jq -r '.visibility')"
  343. if [ "$gaps" -eq 0 ]; then term_panel_line "$(term_mark ok) $r ($vis) — clean" >&2
  344. else term_panel_line "$(term_mark bad) $r ($vis) — $gaps gap(s)/alert(s)" >&2; fi
  345. fi
  346. done
  347. if [ "$JSON" -eq 1 ]; then
  348. jq -c -n --argjson data "$all" --arg org "$ORG" \
  349. --argjson swept "$swept" --argjson unread "$unread" --argjson find "$any_findings" \
  350. '{data:$data, meta:{org:$org, repos_audited:$swept, repos_unreadable:$unread, findings:($find==1), schema:"claude-mods.github-ops.security-posture/v1"}}'
  351. elif [ "$COMMANDS" -eq 1 ]; then
  352. echo "# review before running — these change repo settings" >&2
  353. printf '%s' "$all" | jq -r '.[] | "# \(.repo)", (.features[]|select(.applicable==true and .state=="off")|" \(.enable_command)")'
  354. else
  355. local_health="$([ "$any_findings" -eq 1 ] && term_health warning "$swept swept gaps found" || term_health healthy "$swept swept all clean")"
  356. term_panel_vert >&2
  357. term_panel_close "$(term_color dim "$unread unreadable")" "$local_health" >&2
  358. fi
  359. [ "$any_findings" -eq 1 ] && exit "$EX_FINDINGS"
  360. exit "$EX_OK"
  361. fi
  362. # ---- Single repo ----
  363. if [ -z "$REPO" ]; then
  364. url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
  365. case "$url" in
  366. *github.com[:/]*)
  367. REPO="$(printf '%s' "$url" | tr -d '\r' | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
  368. *) skip "remote '$REMOTE' is not a github.com repo" ;;
  369. esac
  370. fi
  371. valid_repo "$REPO" || { echo "check-security-posture: invalid OWNER/REPO '$REPO'" >&2; exit "$EX_USAGE"; }
  372. obj="$(audit_repo "$REPO")"; rc=$?
  373. if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then skip "couldn't read $REPO (not authed / offline / not found / timeout)"; fi
  374. if [ "$JSON" -eq 1 ]; then
  375. printf '%s' "$obj" | jq -c \
  376. '{data: .features, meta: {repo:.repo, visibility:.visibility, private:.private, ghas:.ghas,
  377. gaps: ([.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown")))]|length),
  378. open_alerts: ([.features[].open_alerts // 0]|add),
  379. schema:"claude-mods.github-ops.security-posture/v1"}}'
  380. elif [ "$COMMANDS" -eq 1 ]; then
  381. print_commands "$obj"
  382. else
  383. print_human "$obj"
  384. fi
  385. [ "$rc" -eq 10 ] && exit "$EX_FINDINGS"
  386. exit "$EX_OK"