check-security-posture.sh 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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. REPO=""; REMOTE="origin"; ORG=""; COMMANDS=0; JSON=0; STRICT=0; ADVISORY=0
  50. while [ $# -gt 0 ]; do
  51. case "$1" in
  52. --repo) REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
  53. --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
  54. --org) ORG="${2:?--org needs an OWNER}"; shift 2 ;;
  55. --commands) COMMANDS=1; shift ;;
  56. --json) JSON=1; shift ;;
  57. --strict) STRICT=1; shift ;;
  58. --advisory) ADVISORY=1; shift ;;
  59. -h|--help) sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
  60. *) echo "check-security-posture: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
  61. esac
  62. done
  63. # In advisory mode, any inability to check is a silent skip.
  64. skip() { # message
  65. [ "$ADVISORY" -eq 1 ] || echo "check-security-posture: $1" >&2
  66. exit "$EX_UNAVAILABLE"
  67. }
  68. command -v gh >/dev/null 2>&1 || {
  69. [ "$ADVISORY" -eq 1 ] && exit "$EX_UNAVAILABLE"
  70. echo "check-security-posture: gh not installed (https://cli.github.com)" >&2
  71. exit "$EX_MISSING_DEP"
  72. }
  73. command -v jq >/dev/null 2>&1 || skip "jq not installed"
  74. runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
  75. # Validate OWNER/REPO shape (agent safety — never interpolate a fabricated path).
  76. valid_repo() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; }
  77. valid_owner() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+$'; }
  78. # --------------------------------------------------------------------------
  79. # Per-repo audit. Emits one JSON object {repo, visibility, ghas, features:[...]}
  80. # to stdout via `printf`. Returns 0 clean / 10 findings / 7 unavailable.
  81. # Never crashes on a read error: unknown reads become state "unknown".
  82. # --------------------------------------------------------------------------
  83. audit_repo() { # OWNER/REPO -> echoes a JSON object, returns 0|10|7
  84. local R="$1" core owner vis priv ghas ss ssp default_branch
  85. owner="${R%%/*}"
  86. core="$(runner gh api "repos/$R" 2>/dev/null)" || return 7
  87. [ -n "$core" ] || return 7
  88. vis="$(printf '%s' "$core" | jq -r '.visibility // (if .private then "private" else "public" end)')"
  89. priv="$(printf '%s' "$core" | jq -r '.private')"
  90. ghas="$(printf '%s' "$core" | jq -r '.security_and_analysis.advanced_security.status // "null"')"
  91. ss="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning.status // "null"')"
  92. ssp="$(printf '%s' "$core" | jq -r '.security_and_analysis.secret_scanning_push_protection.status // "null"')"
  93. default_branch="$(printf '%s' "$core" | jq -r '.default_branch // "main"')"
  94. local is_public=0; [ "$vis" = "public" ] && is_public=1
  95. local has_ghas=0; [ "$ghas" = "enabled" ] && has_ghas=1
  96. # Secret/push/code scanning are "applicable" (a gap if off) when free: public repo,
  97. # OR private repo with GHAS enabled. Otherwise they are a NOTE ("needs GHAS").
  98. local scan_applicable=0
  99. if [ "$is_public" -eq 1 ] || [ "$has_ghas" -eq 1 ]; then scan_applicable=1; fi
  100. # Each feature row appended to this jq array as a compact object.
  101. local features="[]"
  102. add() { # feature state applicable severity enable_command [open_alerts] [max_severity]
  103. features="$(jq -c \
  104. --arg f "$1" --arg st "$2" --argjson ap "$3" --arg sev "$4" --arg cmd "$5" \
  105. --arg oa "${6-}" --arg mx "${7-}" \
  106. '. + [ ($oa|if .=="" then {} else {open_alerts: (.|tonumber)} end)
  107. + ($mx|if .=="" then {} else {max_severity: .} end)
  108. + {feature:$f, state:$st, applicable:$ap, severity:$sev, enable_command:$cmd} ]' \
  109. <<<"$features")"
  110. }
  111. # ---- 1. Dependabot alerts (free on any repo) ----
  112. local da_state da_cmd="gh api -X PUT repos/$R/vulnerability-alerts"
  113. if runner gh api "repos/$R/vulnerability-alerts" --silent >/dev/null 2>&1; then
  114. da_state="on"
  115. else
  116. # 404 = disabled (the normal case). A timeout/auth failure also lands here; we
  117. # can't distinguish without the body, so treat as "off" but it'll be re-checked
  118. # below only if on. Conservative: report off (never a false "on").
  119. da_state="off"
  120. fi
  121. if [ "$da_state" = "on" ]; then
  122. # Enabled -> fetch OPEN alerts for real exposure. 403/404 -> n/a couldn't read.
  123. local da_json da_n da_max=""
  124. da_json="$(runner gh api "repos/$R/dependabot/alerts?state=open&per_page=100" 2>/dev/null)"
  125. if [ -n "$da_json" ] && printf '%s' "$da_json" | jq -e 'type=="array"' >/dev/null 2>&1; then
  126. da_n="$(printf '%s' "$da_json" | jq 'length')"
  127. da_max="$(printf '%s' "$da_json" | jq -r '
  128. ([.[].security_advisory.severity] | map(ascii_downcase)) as $s
  129. | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
  130. add "dependabot_alerts" "on" true "none" "$da_cmd" "$da_n" "$da_max"
  131. else
  132. add "dependabot_alerts" "on" true "none" "$da_cmd" "" "unknown"
  133. fi
  134. else
  135. add "dependabot_alerts" "off" true "$( [ "$is_public" -eq 1 ] && echo high || echo high )" "$da_cmd"
  136. fi
  137. # ---- 2. Dependabot security updates (free on any repo) ----
  138. local asf asf_cmd="gh api -X PUT repos/$R/automated-security-fixes"
  139. asf="$(runner gh api "repos/$R/automated-security-fixes" --jq '.enabled' 2>/dev/null | tr -d '\r')"
  140. case "$asf" in
  141. true) add "dependabot_security_updates" "on" true "none" "$asf_cmd" ;;
  142. false) add "dependabot_security_updates" "off" true "medium" "$asf_cmd" ;;
  143. *) add "dependabot_security_updates" "unknown" true "low" "$asf_cmd" ;;
  144. esac
  145. # ---- 3. Secret scanning (free on public; GHAS on private) ----
  146. local ss_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"}}}'"'"
  147. if [ "$scan_applicable" -eq 1 ]; then
  148. if [ "$ss" = "enabled" ]; then
  149. # On -> count open secret-scanning alerts. 403/404 -> couldn't read.
  150. local sj sn
  151. sj="$(runner gh api "repos/$R/secret-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
  152. if [ -n "$sj" ] && printf '%s' "$sj" | jq -e 'type=="array"' >/dev/null 2>&1; then
  153. sn="$(printf '%s' "$sj" | jq 'length')"
  154. # Any exposed secret is critical.
  155. local sev=none; [ "$sn" -gt 0 ] && sev=critical
  156. add "secret_scanning" "on" true "$sev" "$ss_cmd" "$sn"
  157. else
  158. add "secret_scanning" "on" true "none" "$ss_cmd" "" "unknown"
  159. fi
  160. else
  161. add "secret_scanning" "off" true "medium" "$ss_cmd"
  162. fi
  163. else
  164. add "secret_scanning" "n/a" false "note" "$ss_cmd"
  165. fi
  166. # ---- 4. Push protection (free on public; GHAS on private). Needs secret scanning first. ----
  167. local pp_cmd='gh api -X PATCH repos/'"$R"' --input - <<<'"'"'{"security_and_analysis":{"secret_scanning":{"status":"enabled"},"secret_scanning_push_protection":{"status":"enabled"}}}'"'"
  168. if [ "$scan_applicable" -eq 1 ]; then
  169. if [ "$ssp" = "enabled" ]; then
  170. add "secret_scanning_push_protection" "on" true "none" "$pp_cmd"
  171. else
  172. add "secret_scanning_push_protection" "off" true "high" "$pp_cmd"
  173. fi
  174. else
  175. add "secret_scanning_push_protection" "n/a" false "note" "$pp_cmd"
  176. fi
  177. # ---- 5. Code scanning default setup (free on public; GHAS on private) ----
  178. local cs_state cs_cmd="gh api -X PUT repos/$R/code-scanning/default-setup -f state=configured"
  179. cs_state="$(runner gh api "repos/$R/code-scanning/default-setup" --jq '.state' 2>/dev/null | tr -d '\r')"
  180. if [ "$scan_applicable" -eq 1 ]; then
  181. if [ "$cs_state" = "configured" ]; then
  182. local cj cn cmax=""
  183. cj="$(runner gh api "repos/$R/code-scanning/alerts?state=open&per_page=100" 2>/dev/null)"
  184. if [ -n "$cj" ] && printf '%s' "$cj" | jq -e 'type=="array"' >/dev/null 2>&1; then
  185. cn="$(printf '%s' "$cj" | jq 'length')"
  186. cmax="$(printf '%s' "$cj" | jq -r '
  187. ([.[].rule.security_severity_level // .[].rule.severity // empty] | map(ascii_downcase)) as $s
  188. | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // ""')"
  189. add "code_scanning" "on" true "none" "$cs_cmd" "$cn" "$cmax"
  190. else
  191. add "code_scanning" "on" true "none" "$cs_cmd" "" "unknown"
  192. fi
  193. elif [ -n "$cs_state" ] && [ "$cs_state" != "null" ]; then
  194. add "code_scanning" "off" true "medium" "$cs_cmd" # not-configured
  195. else
  196. add "code_scanning" "unknown" true "low" "$cs_cmd" # couldn't read
  197. fi
  198. else
  199. add "code_scanning" "n/a" false "note" "$cs_cmd"
  200. fi
  201. # ---- 6. Private vulnerability reporting (free on any repo) ----
  202. local pvr pvr_cmd="gh api -X PUT repos/$R/private-vulnerability-reporting"
  203. pvr="$(runner gh api "repos/$R/private-vulnerability-reporting" --jq '.enabled' 2>/dev/null | tr -d '\r')"
  204. case "$pvr" in
  205. true) add "private_vulnerability_reporting" "on" true "none" "$pvr_cmd" ;;
  206. false) add "private_vulnerability_reporting" "off" true "low" "$pvr_cmd" ;;
  207. *) add "private_vulnerability_reporting" "unknown" true "low" "$pvr_cmd" ;;
  208. esac
  209. # ---- 7. SECURITY.md present (root, .github/, docs/) ----
  210. local sec_found=0 loc
  211. for loc in "SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md"; do
  212. if runner gh api "repos/$R/contents/$loc" --silent >/dev/null 2>&1; then sec_found=1; break; fi
  213. done
  214. local sec_cmd="cp assets/SECURITY.md.template SECURITY.md # edit, commit, push"
  215. if [ "$sec_found" -eq 1 ]; then
  216. add "security_policy" "on" true "none" "$sec_cmd"
  217. else
  218. add "security_policy" "off" true "low" "$sec_cmd"
  219. fi
  220. # ---- 8. Branch protection on the default branch (bonus) ----
  221. local bp_cmd="# branch protection: see github.com/$R/settings/branches (requires a ruleset/protection JSON)"
  222. if runner gh api "repos/$R/branches/$default_branch/protection" --silent >/dev/null 2>&1; then
  223. add "branch_protection" "on" true "none" "$bp_cmd"
  224. else
  225. # 404 not-protected / 403 no-access -> treat as off (free to set on any repo).
  226. add "branch_protection" "off" true "medium" "$bp_cmd"
  227. fi
  228. # Assemble the repo object and decide the per-repo exit.
  229. local obj
  230. obj="$(jq -c -n --arg repo "$R" --arg vis "$vis" --argjson priv "${priv:-false}" \
  231. --arg ghas "$ghas" --argjson feat "$features" \
  232. '{repo:$repo, visibility:$vis, private:$priv,
  233. ghas:(if $ghas=="null" then null else $ghas end), features:$feat}')"
  234. printf '%s' "$obj"
  235. # Findings = any applicable feature that is off/unknown, OR any open_alerts>0.
  236. local gaps
  237. gaps="$(printf '%s' "$obj" | jq '
  238. [ .features[]
  239. | select(.applicable == true)
  240. | select( (.state=="off") or (.state=="unknown") or ((.open_alerts // 0) > 0) )
  241. ] | length')"
  242. [ "$gaps" -gt 0 ] && return 10
  243. return 0
  244. }
  245. # Severity glyph helper for human output.
  246. sev_tag() { case "$1" in
  247. critical) printf '[critical]';; high) printf '[high]';;
  248. medium) printf '[medium]';; low) printf '[low]';;
  249. note) printf '';; *) printf '';; esac; }
  250. # Human checklist for one repo object (reads JSON on stdin-arg $1).
  251. print_human() { # repo_json
  252. local o="$1" repo vis
  253. repo="$(printf '%s' "$o" | jq -r '.repo')"
  254. vis="$(printf '%s' "$o" | jq -r '.visibility')"
  255. {
  256. echo "SECURITY POSTURE — $repo ($vis)"
  257. printf '%s' "$o" | jq -r '
  258. .features[] |
  259. if .state=="on" then
  260. " ✓ \(.feature)" +
  261. (if (.open_alerts // 0) > 0 then " — \(.open_alerts) OPEN alert(s)" + (if .max_severity then ", max \(.max_severity)" else "" end) else "" end) +
  262. (if .max_severity=="unknown" then " (alerts: couldn’t read — needs security_events scope)" else "" end)
  263. elif .state=="n/a" then
  264. " — \(.feature) n/a (needs GitHub Advanced Security on a private repo)"
  265. elif .state=="unknown" then
  266. " ? \(.feature) n/a (couldn’t read)"
  267. else
  268. " ✗ \(.feature) [\(.severity)]"
  269. end'
  270. # Enable commands for gaps.
  271. local has_gap
  272. has_gap="$(printf '%s' "$o" | jq '[.features[]|select(.applicable==true and (.state=="off"))]|length')"
  273. if [ "$has_gap" -gt 0 ]; then
  274. echo " ── enable commands (review before running; this script never runs them):"
  275. printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|" \(.enable_command)"'
  276. fi
  277. } >&2
  278. }
  279. # Emit ONLY the enable commands (data on stdout; banner on stderr).
  280. print_commands() { # repo_json
  281. local o="$1"
  282. echo "# review before running — these change repo settings" >&2
  283. printf '%s' "$o" | jq -r '.features[]|select(.applicable==true and .state=="off")|.enable_command'
  284. }
  285. # ==========================================================================
  286. # Mode dispatch
  287. # ==========================================================================
  288. # Conflicting selectors.
  289. sel=0
  290. [ -n "$REPO" ] && sel=$((sel+1))
  291. [ -n "$ORG" ] && sel=$((sel+1))
  292. if [ "$sel" -gt 1 ]; then
  293. echo "check-security-posture: --repo and --org are mutually exclusive" >&2; exit "$EX_USAGE"
  294. fi
  295. # ---- Fleet sweep ----
  296. if [ -n "$ORG" ]; then
  297. valid_owner "$ORG" || { echo "check-security-posture: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
  298. list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner 2>/dev/null)" \
  299. || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
  300. [ -n "$list" ] || skip "no repos returned for $ORG"
  301. mapfile -t repos < <(printf '%s' "$list" | jq -r '.[].nameWithOwner' | tr -d '\r')
  302. [ "${#repos[@]}" -gt 0 ] || skip "no non-archived repos for $ORG"
  303. all="[]"; any_findings=0; swept=0; unread=0
  304. for r in "${repos[@]}"; do
  305. valid_repo "$r" || continue
  306. obj="$(audit_repo "$r")"; rc=$?
  307. if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then
  308. unread=$((unread+1))
  309. [ "$JSON" -eq 1 ] || echo " ? $r — couldn't read (skipped)" >&2
  310. continue
  311. fi
  312. swept=$((swept+1))
  313. [ "$rc" -eq 10 ] && any_findings=1
  314. all="$(jq -c --argjson o "$obj" '. + [$o]' <<<"$all")"
  315. if [ "$JSON" -eq 0 ] && [ "$COMMANDS" -eq 0 ]; then
  316. gaps="$(printf '%s' "$obj" | jq '[.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown") or ((.open_alerts//0)>0)))]|length')"
  317. vis="$(printf '%s' "$obj" | jq -r '.visibility')"
  318. if [ "$gaps" -eq 0 ]; then echo " ✓ $r ($vis) — clean" >&2
  319. else echo " ✗ $r ($vis) — $gaps gap(s)/alert(s)" >&2; fi
  320. fi
  321. done
  322. if [ "$JSON" -eq 1 ]; then
  323. jq -c -n --argjson data "$all" --arg org "$ORG" \
  324. --argjson swept "$swept" --argjson unread "$unread" --argjson find "$any_findings" \
  325. '{data:$data, meta:{org:$org, repos_audited:$swept, repos_unreadable:$unread, findings:($find==1), schema:"claude-mods.github-ops.security-posture/v1"}}'
  326. elif [ "$COMMANDS" -eq 1 ]; then
  327. echo "# review before running — these change repo settings" >&2
  328. printf '%s' "$all" | jq -r '.[] | "# \(.repo)", (.features[]|select(.applicable==true and .state=="off")|" \(.enable_command)")'
  329. else
  330. echo "── swept $swept repo(s) in $ORG; $unread unreadable. ✗ = action available." >&2
  331. fi
  332. [ "$any_findings" -eq 1 ] && exit "$EX_FINDINGS"
  333. exit "$EX_OK"
  334. fi
  335. # ---- Single repo ----
  336. if [ -z "$REPO" ]; then
  337. url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
  338. case "$url" in
  339. *github.com[:/]*)
  340. REPO="$(printf '%s' "$url" | tr -d '\r' | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
  341. *) skip "remote '$REMOTE' is not a github.com repo" ;;
  342. esac
  343. fi
  344. valid_repo "$REPO" || { echo "check-security-posture: invalid OWNER/REPO '$REPO'" >&2; exit "$EX_USAGE"; }
  345. obj="$(audit_repo "$REPO")"; rc=$?
  346. if [ "$rc" -eq 7 ] || [ -z "$obj" ]; then skip "couldn't read $REPO (not authed / offline / not found / timeout)"; fi
  347. if [ "$JSON" -eq 1 ]; then
  348. printf '%s' "$obj" | jq -c \
  349. '{data: .features, meta: {repo:.repo, visibility:.visibility, private:.private, ghas:.ghas,
  350. gaps: ([.features[]|select(.applicable==true and ((.state=="off") or (.state=="unknown")))]|length),
  351. open_alerts: ([.features[].open_alerts // 0]|add),
  352. schema:"claude-mods.github-ops.security-posture/v1"}}'
  353. elif [ "$COMMANDS" -eq 1 ]; then
  354. print_commands "$obj"
  355. else
  356. print_human "$obj"
  357. fi
  358. [ "$rc" -eq 10 ] && exit "$EX_FINDINGS"
  359. exit "$EX_OK"