|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +# Single entrypoint for the weekly security scan report. Only the `scan` and |
| 5 | +# `summary` subcommands know about the scanner (currently Trivy); the summary, |
| 6 | +# report, and notification formats are scanner-agnostic so the implementation |
| 7 | +# can be swapped without changing the workflow contract. |
| 8 | +# |
| 9 | +# Subcommands: |
| 10 | +# scan <src-dir> <out.json> [trivy args...] Run `trivy fs` over <src-dir> and write the |
| 11 | +# raw JSON report to <out.json>. Extra args are passed |
| 12 | +# through to trivy (e.g. --skip-db-update). |
| 13 | +# summary <scan.json> <out.json> Normalize a raw scan report into a flat findings |
| 14 | +# summary keyed for diffing, with per-severity counts. |
| 15 | +# compare <cur> <prev> [out] Diff two summaries -> report JSON with new and fixed |
| 16 | +# findings. A missing/empty previous summary yields a |
| 17 | +# baseline (current-only) report. |
| 18 | +# render <report.json> Render the report as Markdown to $GITHUB_STEP_SUMMARY |
| 19 | +# (or stdout). |
| 20 | +# payload <report.json> Print the Slack message payload JSON to stdout. |
| 21 | +# notify <report.json> Post the report to Slack ($SECURITY_SCAN_SLACK_WEBHOOK_URL). |
| 22 | +# notify-failure Post a run-failure notice to Slack so a broken scan |
| 23 | +# is not a silent missed week. |
| 24 | +# |
| 25 | +# Env knobs — SECURITY_SCAN_* configure the scanner-agnostic reporting; |
| 26 | +# TRIVY_* configure the scanner itself (intentionally shadowing trivy's native |
| 27 | +# env config; the explicit flags passed by `scan` always win): |
| 28 | +# SECURITY_SCAN_RENDER_LIMIT Max current findings rendered in the step summary (default: 50). |
| 29 | +# SECURITY_SCAN_NOTIFY_LIMIT Max new/fixed findings listed in the Slack message (default: 10). |
| 30 | +# TRIVY_SCANNERS Scanners for `scan` (default: vuln,misconfig,secret). |
| 31 | +# TRIVY_SKIP_DIRS Comma-separated dirs `scan` skips (default: test and |
| 32 | +# docs/src/fixtures, whose intentionally-simple fixtures |
| 33 | +# would drown the report). |
| 34 | +# TRIVY_TIMEOUT Trivy scan timeout (default: 15m). |
| 35 | +# |
| 36 | +# Pass a missing-previous path as /nonexistent to `compare` to get a baseline report. |
| 37 | + |
| 38 | +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" |
| 39 | + |
| 40 | +usage() { |
| 41 | + sed -n '/^# Single entrypoint/,/to get a baseline report\.$/p' "$SELF" |
| 42 | +} |
| 43 | + |
| 44 | +now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } |
| 45 | +this_commit() { echo "${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}"; } |
| 46 | + |
| 47 | +# Shared jq defs: severity handling, the fields every finding class has in |
| 48 | +# common, and the count/finding/list output formats. |
| 49 | +jq_lib() { |
| 50 | + cat <<-'JQ' |
| 51 | + def sev: if IN("CRITICAL", "HIGH", "MEDIUM", "LOW") then . else "UNKNOWN" end; |
| 52 | + def sevrank: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}[.] // 4; |
| 53 | + def base($r): { |
| 54 | + id: "", pkg: "", installed: "", fixed: "", line: 0, |
| 55 | + severity: (.Severity | sev), target: ($r.Target // ""), title: (.Title // "") |
| 56 | + }; |
| 57 | + def counts: {CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, UNKNOWN: 0} |
| 58 | + + (group_by(.severity) | map({(.[0].severity): length}) | add // {}) |
| 59 | + + {total: length}; |
| 60 | + def counts_line: . as $c | |
| 61 | + (["CRITICAL", "HIGH", "MEDIUM", "LOW"] |
| 62 | + | map(select(($c[.] // 0) > 0) | "\($c[.]) \(.)") |
| 63 | + | join(", ")) as $by_sev | |
| 64 | + "\($c.total // 0) total" + (if $by_sev != "" then " (\($by_sev))" else "" end); |
| 65 | + def loc: .target + (if (.line // 0) > 0 then ":\(.line)" else "" end); |
| 66 | + def finding_line: |
| 67 | + (if .severity != "UNKNOWN" then "[\(.severity)] " else "" end) |
| 68 | + + (if .class == "vuln" then |
| 69 | + "\(.pkg) \(.installed)" |
| 70 | + + (if .fixed != "" then " -> \(.fixed)" else "" end) |
| 71 | + + " \(.id)" |
| 72 | + else |
| 73 | + .id + (if .title != "" then " \(.title)" else "" end) |
| 74 | + end) |
| 75 | + + " (\(loc))"; |
| 76 | + def listed($items; $label; $limit): if ($items | length) == 0 then "" else |
| 77 | + "\($label) (\($items | length)):\n" |
| 78 | + + ([$items[:$limit][] | " \(finding_line)"] | join("\n")) |
| 79 | + + (if ($items | length) > $limit |
| 80 | + then "\n ...and \(($items | length) - $limit) more" else "" end) |
| 81 | + end; |
| 82 | + JQ |
| 83 | +} |
| 84 | + |
| 85 | +# Run `trivy fs` over a source tree and write the raw JSON report |
| 86 | +cmd_scan() { |
| 87 | + local src="${1:?Usage: security-scan-report.sh scan <src-dir> <out.json> [trivy args...]}" |
| 88 | + local out="${2:?Usage: security-scan-report.sh scan <src-dir> <out.json> [trivy args...]}" |
| 89 | + shift 2 |
| 90 | + |
| 91 | + mkdir -p "$(dirname "$out")" |
| 92 | + |
| 93 | + # Findings never fail the scan (--exit-code 0): the diff report is the deliverable. |
| 94 | + trivy fs \ |
| 95 | + --scanners "${TRIVY_SCANNERS:-vuln,misconfig,secret}" \ |
| 96 | + --skip-dirs "${TRIVY_SKIP_DIRS:-test,docs/src/fixtures}" \ |
| 97 | + --timeout "${TRIVY_TIMEOUT:-15m}" \ |
| 98 | + --format json \ |
| 99 | + --output "$out" \ |
| 100 | + --exit-code 0 \ |
| 101 | + --no-progress \ |
| 102 | + "$@" \ |
| 103 | + "$src" |
| 104 | + |
| 105 | + echo "Scan: $src" |
| 106 | + echo "Report: $out ($(jq '[.Results[]? | ((.Vulnerabilities // []) + (.Misconfigurations // []) + (.Secrets // []))] | flatten | length' "$out") raw findings)" |
| 107 | +} |
| 108 | + |
| 109 | +# Normalize a raw Trivy report into a flat, diffable findings summary |
| 110 | +cmd_summary() { |
| 111 | + local input="${1:?Usage: security-scan-report.sh summary <trivy.json> <out.json>}" |
| 112 | + local output="${2:?Usage: security-scan-report.sh summary <trivy.json> <out.json>}" |
| 113 | + |
| 114 | + if [[ ! -f "$input" ]]; then |
| 115 | + echo "Error: trivy report '$input' not found" >&2 |
| 116 | + exit 1 |
| 117 | + fi |
| 118 | + |
| 119 | + if ! jq empty "$input" 2>/dev/null; then |
| 120 | + echo "Error: trivy report '$input' is not valid JSON" >&2 |
| 121 | + exit 1 |
| 122 | + fi |
| 123 | + |
| 124 | + mkdir -p "$(dirname "$output")" |
| 125 | + |
| 126 | + # One record per unique (class, target, pkg, id); passing misconfigurations are dropped. |
| 127 | + jq \ |
| 128 | + --arg commit "$(this_commit)" \ |
| 129 | + --arg timestamp "$(now_utc)" \ |
| 130 | + "$(jq_lib)"' |
| 131 | + [.Results[]? as $r | |
| 132 | + (($r.Vulnerabilities // [])[] | base($r) + { |
| 133 | + class: "vuln", |
| 134 | + id: (.VulnerabilityID // ""), |
| 135 | + pkg: (.PkgName // ""), |
| 136 | + installed: (.InstalledVersion // ""), |
| 137 | + fixed: (.FixedVersion // "") |
| 138 | + }), |
| 139 | + (($r.Misconfigurations // [])[] | select((.Status // "FAIL") == "FAIL") | base($r) + { |
| 140 | + class: "misconfig", |
| 141 | + id: (.AVDID // .ID // ""), |
| 142 | + line: (.CauseMetadata.StartLine // 0) |
| 143 | + }), |
| 144 | + (($r.Secrets // [])[] | base($r) + { |
| 145 | + class: "secret", |
| 146 | + id: (.RuleID // ""), |
| 147 | + line: (.StartLine // 0) |
| 148 | + }) |
| 149 | + ] |
| 150 | + | map(. + {key: "\(.class)|\(.target)|\(.pkg)|\(.id)"}) |
| 151 | + | unique_by(.key) |
| 152 | + | sort_by([(.severity | sevrank), .key]) |
| 153 | + | {commit: $commit, timestamp: $timestamp, counts: counts, findings: .}' "$input" >"$output" |
| 154 | + |
| 155 | + echo "Summary: $output ($(jq -r '.counts.total' "$output") findings)" |
| 156 | +} |
| 157 | + |
| 158 | +# Diff two findings summaries into a new/fixed report |
| 159 | +cmd_compare() { |
| 160 | + local current="${1:?Usage: security-scan-report.sh compare <current.json> <previous.json> [output.json]}" |
| 161 | + local previous="${2:?Usage: security-scan-report.sh compare <current.json> <previous.json> [output.json]}" |
| 162 | + local output="${3:-security-scan-report.json}" |
| 163 | + |
| 164 | + if [[ ! -f "$current" ]]; then |
| 165 | + echo "Error: current summary '$current' not found" >&2 |
| 166 | + exit 1 |
| 167 | + fi |
| 168 | + |
| 169 | + if [[ ! -s "$previous" ]]; then |
| 170 | + echo "No previous scan data found: establishing baseline." |
| 171 | + jq '{ |
| 172 | + baseline: true, |
| 173 | + current_counts: .counts, |
| 174 | + previous_counts: null, |
| 175 | + new: [], |
| 176 | + fixed: [], |
| 177 | + unchanged: null, |
| 178 | + current_findings: .findings |
| 179 | + }' "$current" >"$output" |
| 180 | + return 0 |
| 181 | + fi |
| 182 | + |
| 183 | + jq -n \ |
| 184 | + --slurpfile cur "$current" \ |
| 185 | + --slurpfile prev "$previous" ' |
| 186 | + ($cur[0]) as $c | |
| 187 | + ($prev[0]) as $p | |
| 188 | + INDEX($c.findings[]; .key) as $ckeys | |
| 189 | + INDEX($p.findings[]; .key) as $pkeys | |
| 190 | + { |
| 191 | + baseline: false, |
| 192 | + current_counts: $c.counts, |
| 193 | + previous_counts: $p.counts, |
| 194 | + new: [$c.findings[] | select($pkeys[.key] | not)], |
| 195 | + fixed: [$p.findings[] | select($ckeys[.key] | not)], |
| 196 | + unchanged: ([$c.findings[] | select($pkeys[.key])] | length), |
| 197 | + current_findings: $c.findings |
| 198 | + }' >"$output" |
| 199 | + |
| 200 | + echo "Report: $output ($(jq -r '.new | length' "$output") new, $(jq -r '.fixed | length' "$output") fixed)" |
| 201 | +} |
| 202 | + |
| 203 | +# Render the report as Markdown to $GITHUB_STEP_SUMMARY (or stdout) |
| 204 | +cmd_render() { |
| 205 | + local report="${1:?Usage: security-scan-report.sh render <report.json>}" |
| 206 | + local summary_file="${GITHUB_STEP_SUMMARY:-/dev/stdout}" |
| 207 | + local limit="${SECURITY_SCAN_RENDER_LIMIT:-50}" |
| 208 | + |
| 209 | + if [[ ! -f "$report" ]]; then |
| 210 | + echo "No trivy report at '$report'; skipping summary." >&2 |
| 211 | + return 0 |
| 212 | + fi |
| 213 | + |
| 214 | + write() { echo "$@" >>"$summary_file"; } |
| 215 | + |
| 216 | + table() { |
| 217 | + echo "| Severity | Class | ID | Location | Package | Installed | Fixed |" |
| 218 | + echo "|----------|-------|----|----------|---------|-----------|-------|" |
| 219 | + jq -r "$(jq_lib)"'.[] | "| \(.severity) | \(.class) | \(.id) | \(loc) | \(.pkg) | \(.installed) | \(.fixed) |"' |
| 220 | + } |
| 221 | + |
| 222 | + write "## Weekly Security Scan" |
| 223 | + write "" |
| 224 | + |
| 225 | + local baseline |
| 226 | + baseline=$(jq -r '.baseline' "$report") |
| 227 | + if [[ "$baseline" == "true" ]]; then |
| 228 | + write "Baseline established: $(jq -r "$(jq_lib)"'.current_counts | counts_line' "$report")" |
| 229 | + else |
| 230 | + write "| Severity | Current | Previous |" |
| 231 | + write "|----------|---------|----------|" |
| 232 | + jq -r '["CRITICAL","HIGH","MEDIUM","LOW","UNKNOWN","total"][] as $s | |
| 233 | + "| \($s) | \(.current_counts[$s] // 0) | \(.previous_counts[$s] // 0) |"' "$report" >>"$summary_file" |
| 234 | + write "" |
| 235 | + |
| 236 | + local new_count fixed_count |
| 237 | + new_count=$(jq -r '.new | length' "$report") |
| 238 | + fixed_count=$(jq -r '.fixed | length' "$report") |
| 239 | + |
| 240 | + write "### New findings ($new_count)" |
| 241 | + write "" |
| 242 | + if [[ "$new_count" -gt 0 ]]; then |
| 243 | + jq -c '.new' "$report" | table >>"$summary_file" |
| 244 | + else |
| 245 | + write "No new findings this week." |
| 246 | + fi |
| 247 | + write "" |
| 248 | + |
| 249 | + write "### Fixed findings ($fixed_count)" |
| 250 | + write "" |
| 251 | + if [[ "$fixed_count" -gt 0 ]]; then |
| 252 | + jq -c '.fixed' "$report" | table >>"$summary_file" |
| 253 | + else |
| 254 | + write "No fixed findings this week." |
| 255 | + fi |
| 256 | + fi |
| 257 | + write "" |
| 258 | + |
| 259 | + local total shown |
| 260 | + total=$(jq -r '.current_findings | length' "$report") |
| 261 | + shown=$((total < limit ? total : limit)) |
| 262 | + |
| 263 | + write "### All current findings ($total)" |
| 264 | + write "" |
| 265 | + if [[ "$total" -gt 0 ]]; then |
| 266 | + write "<details><summary>Showing $shown of $total</summary>" |
| 267 | + write "" |
| 268 | + jq -c --argjson limit "$limit" '.current_findings[:$limit]' "$report" | table >>"$summary_file" |
| 269 | + if [[ "$total" -gt "$limit" ]]; then |
| 270 | + write "" |
| 271 | + write "...and $((total - limit)) more; see the security-scan-report.json artifact." |
| 272 | + fi |
| 273 | + write "" |
| 274 | + write "</details>" |
| 275 | + else |
| 276 | + write "No findings." |
| 277 | + fi |
| 278 | +} |
| 279 | + |
| 280 | +# Print the Slack message payload for the report to stdout |
| 281 | +cmd_payload() { |
| 282 | + local report="${1:?Usage: security-scan-report.sh payload <report.json>}" |
| 283 | + local limit="${SECURITY_SCAN_NOTIFY_LIMIT:-10}" |
| 284 | + local repo="${REPO:-gruntwork-io/terragrunt}" |
| 285 | + local run_url="${GITHUB_SERVER_URL:-https://github.com}/${repo}/actions/runs/${GITHUB_RUN_ID:-0}" |
| 286 | + |
| 287 | + if [[ ! -f "$report" ]]; then |
| 288 | + echo "Error: trivy report '$report' not found" >&2 |
| 289 | + exit 1 |
| 290 | + fi |
| 291 | + |
| 292 | + # Header: one dated line per endpoint, matching the weekly coverage report. |
| 293 | + local cur_sha="${CURRENT_SHA:-}" cur_date="${CURRENT_DATE:-unknown}" |
| 294 | + local prev_sha="${PREVIOUS_SHA:-}" prev_date="${PREVIOUS_DATE:-unknown}" |
| 295 | + local header="*Weekly Security Scan: terragrunt*" |
| 296 | + if [[ -n "$cur_sha" && -n "$prev_sha" ]]; then |
| 297 | + header+=$'\n'"From: ${prev_date} ${prev_sha}" |
| 298 | + header+=$'\n'"To: ${cur_date} ${cur_sha}" |
| 299 | + elif [[ -n "$cur_sha" ]]; then |
| 300 | + header+=$'\n'"At: ${cur_date} ${cur_sha}" |
| 301 | + fi |
| 302 | + |
| 303 | + jq -n \ |
| 304 | + --arg header "$header" \ |
| 305 | + --arg run_url "$run_url" \ |
| 306 | + --argjson limit "$limit" \ |
| 307 | + --slurpfile rep "$report" \ |
| 308 | + "$(jq_lib)"' |
| 309 | + ($rep[0]) as $r | |
| 310 | +
|
| 311 | + (if $r.baseline then |
| 312 | + "Findings baseline: \($r.current_counts | counts_line)" |
| 313 | + else |
| 314 | + "Findings: \($r.current_counts | counts_line) (was \($r.previous_counts | counts_line))" |
| 315 | + end) as $totals | |
| 316 | +
|
| 317 | + listed($r.new; "New this week"; $limit) as $new | |
| 318 | + (if ($r.baseline | not) and $new == "" then "No new findings this week." else "" end) as $none | |
| 319 | +
|
| 320 | + {text: ([ |
| 321 | + $header, |
| 322 | + $totals, |
| 323 | + $new, |
| 324 | + listed($r.fixed; "Fixed this week"; $limit), |
| 325 | + $none, |
| 326 | + listed($r.current_findings; "Current findings"; $limit), |
| 327 | + "<\($run_url)|View workflow run>" |
| 328 | + ] | map(select(. != "")) | join("\n\n"))}' |
| 329 | +} |
| 330 | + |
| 331 | +# Post the report to Slack |
| 332 | +cmd_notify() { |
| 333 | + local webhook="${SECURITY_SCAN_SLACK_WEBHOOK_URL:?Required environment variable SECURITY_SCAN_SLACK_WEBHOOK_URL}" |
| 334 | + local report="${1:?Usage: security-scan-report.sh notify <report.json>}" |
| 335 | + |
| 336 | + local payload |
| 337 | + payload=$(cmd_payload "$report") |
| 338 | + |
| 339 | + curl -sS --fail-with-body --connect-timeout 10 --max-time 30 \ |
| 340 | + -X POST -H "Content-Type: application/json" -d "$payload" "$webhook" |
| 341 | + echo "Slack notification sent." |
| 342 | +} |
| 343 | + |
| 344 | +# Post a run-failure notice to Slack |
| 345 | +cmd_notify_failure() { |
| 346 | + local webhook="${SECURITY_SCAN_SLACK_WEBHOOK_URL:?Required environment variable SECURITY_SCAN_SLACK_WEBHOOK_URL}" |
| 347 | + local repo="${REPO:-gruntwork-io/terragrunt}" |
| 348 | + local run_url="${GITHUB_SERVER_URL:-https://github.com}/${repo}/actions/runs/${GITHUB_RUN_ID:-0}" |
| 349 | + |
| 350 | + local payload |
| 351 | + payload=$(jq -n --arg run_url "$run_url" \ |
| 352 | + '{text: "*Weekly Security Scan: terragrunt*\nRun failed before producing a report.\n\n<\($run_url)|View workflow run>"}') |
| 353 | + |
| 354 | + curl -sS --fail-with-body --connect-timeout 10 --max-time 30 \ |
| 355 | + -X POST -H "Content-Type: application/json" -d "$payload" "$webhook" |
| 356 | + echo "Slack failure notification sent." |
| 357 | +} |
| 358 | + |
| 359 | +# Fail before any work when a subcommand's tools are missing |
| 360 | +require_tools() { |
| 361 | + local tool missing=() |
| 362 | + for tool in "$@"; do |
| 363 | + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") |
| 364 | + done |
| 365 | + if [[ ${#missing[@]} -gt 0 ]]; then |
| 366 | + echo "Error: not found on PATH: ${missing[*]}. Install them (e.g. aquasecurity/setup-trivy for trivy) first." >&2 |
| 367 | + exit 1 |
| 368 | + fi |
| 369 | +} |
| 370 | + |
| 371 | +# Route the subcommand to its handler, after checking its pre-reqs |
| 372 | +main() { |
| 373 | + local cmd="${1:-}" |
| 374 | + shift || true |
| 375 | + case "$cmd" in |
| 376 | + scan) require_tools jq trivy && cmd_scan "$@" ;; |
| 377 | + summary) require_tools jq && cmd_summary "$@" ;; |
| 378 | + compare) require_tools jq && cmd_compare "$@" ;; |
| 379 | + render) require_tools jq && cmd_render "$@" ;; |
| 380 | + payload) require_tools jq && cmd_payload "$@" ;; |
| 381 | + notify) require_tools jq curl && cmd_notify "$@" ;; |
| 382 | + notify-failure) require_tools jq curl && cmd_notify_failure "$@" ;; |
| 383 | + "" | -h | --help | help) usage ;; |
| 384 | + *) |
| 385 | + echo "Unknown subcommand: $cmd" >&2 |
| 386 | + usage >&2 |
| 387 | + exit 1 |
| 388 | + ;; |
| 389 | + esac |
| 390 | +} |
| 391 | + |
| 392 | +main "$@" |
0 commit comments