Skip to content

Commit efd1747

Browse files
committed
Merge remote-tracking branch 'origin/main' into outputs-reading-from-state
2 parents 4a21197 + 699f617 commit efd1747

355 files changed

Lines changed: 7615 additions & 2886 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
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

Comments
 (0)