CI: report Trivy misconfiguration findings for changed charts
Adds a trivy-config job to the PR pipeline that renders each changed chart with Helm and runs `trivy config` over the rendered manifests. Reporting only - TRIVY_CONFIG_MODE=report always exits 0, so the job never blocks a PR. Rendered manifests rather than the chart directory: `trivy config <chart-dir>` renders with default values, which fails on every chart here because of their required() values - and Trivy does not fail with it, it logs "Skipping chart" and prints an empty, green report. Values come from the install-test fixtures (ci/<chart>/test-values.yaml), the only committed per-chart values in the repo; none of them touches securityContext, images or capabilities, so the scan sees the chart's own hardening. Charts without fixtures get ci/trivy/values/<chart>.yaml. Exceptions live in ci/trivy/trivyignore.yaml in Trivy's native ignore-file schema, each with a statement - the script refuses to run without one and reports suppressed findings with their reason instead of hiding them. AVD-KSV-0125 is configured instead of excepted: ci/trivy/data/ksv0125.yaml lists the trusted registries, so the check stays able to flag a genuinely foreign source. Refs #84.
This commit is contained in:
5 files changed
+395
-1
No files matched your search
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# Misconfiguration scan for one chart: renders the chart with Helm and runs
|
||||
# `trivy config` over the *rendered* manifests.
|
||||
#
|
||||
# Usage: ci/run-trivy-config.sh <chart-name>
|
||||
#
|
||||
# Why the manifests and not the chart directory: `trivy config <chart-dir>`
|
||||
# renders the chart itself, with default values only. Every chart here has
|
||||
# `required()` values, so that render fails - and Trivy does not fail with it,
|
||||
# it logs "WARN Skipping chart" and prints an empty, green report. A scan that
|
||||
# silently reports "clean" for every chart in the repo is worse than no scan.
|
||||
# Rendering with Helm puts the failure where it belongs: this script exits
|
||||
# non-zero when the render breaks.
|
||||
#
|
||||
# Which values (see README "Install-test"): the same committed fixtures the
|
||||
# install-test uses, ci/<chart>/test-values.yaml. They exist, they are
|
||||
# maintained, and they are proven to produce a running workload. They also do
|
||||
# not touch the security posture - no test-values file overrides
|
||||
# securityContext, images or capabilities (only minecraft-server sets
|
||||
# resources.requests), so what Trivy sees is the chart's own hardening, not a
|
||||
# CI-specific one.
|
||||
#
|
||||
# Charts without install-test fixtures (ci/excluded-charts.txt) are still
|
||||
# scanned - rendering costs nothing, and the reason for excluding them is the
|
||||
# image size, which a static scan never touches. They get their values from
|
||||
# ci/trivy/values/<chart>.yaml, or none at all if the chart renders on defaults.
|
||||
#
|
||||
# Exceptions live in ci/trivy/trivyignore.yaml in Trivy's native ignore-file
|
||||
# schema, one `statement` per entry (same rule as ci/excluded-charts.txt: no
|
||||
# entry without a reason). Findings covered by it are reported separately, not
|
||||
# hidden. The trusted-registry check is configured rather than excepted, via
|
||||
# ci/trivy/data/ksv0125.yaml - see the comment in that file.
|
||||
#
|
||||
# Mode (TRIVY_CONFIG_MODE, default "report"):
|
||||
# report always exits 0 - findings are reported, the PR is not blocked.
|
||||
# gate exits 1 when a finding is not covered by ci/trivy/trivyignore.yaml.
|
||||
# Promotion to a gate is this one variable, once the seccomp gap (#84 part 2)
|
||||
# is closed and the actionable set is genuinely empty.
|
||||
set -euo pipefail
|
||||
|
||||
CHART="${1:?usage: $0 <chart-name>}"
|
||||
MODE="${TRIVY_CONFIG_MODE:-report}"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CI_DIR="$ROOT/ci"
|
||||
IGNORE_FILE="$CI_DIR/trivy/trivyignore.yaml"
|
||||
DATA_DIR="$CI_DIR/trivy/data"
|
||||
RELEASE="scan"
|
||||
|
||||
if [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
|
||||
echo "ERROR: '$CHART' is not a chart directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$IGNORE_FILE" ]; then
|
||||
echo "ERROR: missing $IGNORE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
# The rendered file is named after the chart: ignore entries scope themselves to
|
||||
# a single chart via `paths: ["**/<chart>.yaml"]`, which is how a chart-specific
|
||||
# deviation (samba's writable root filesystem) stays an exception for that chart
|
||||
# and keeps being reported for every other one.
|
||||
RENDERED="$WORK/$CHART.yaml"
|
||||
|
||||
# --- 1. Pick the values ------------------------------------------------------
|
||||
VALUES_ARGS=()
|
||||
VALUES_LABEL="chart defaults (no values file)"
|
||||
if [ -f "$CI_DIR/$CHART/test-values.yaml" ]; then
|
||||
VALUES_ARGS=(-f "$CI_DIR/$CHART/test-values.yaml")
|
||||
VALUES_LABEL="ci/$CHART/test-values.yaml"
|
||||
elif [ -f "$CI_DIR/trivy/values/$CHART.yaml" ]; then
|
||||
VALUES_ARGS=(-f "$CI_DIR/trivy/values/$CHART.yaml")
|
||||
VALUES_LABEL="ci/trivy/values/$CHART.yaml"
|
||||
fi
|
||||
|
||||
# --- 2. Every ignore entry needs a reason ------------------------------------
|
||||
python3 - "$IGNORE_FILE" <<'PY'
|
||||
import re, sys
|
||||
# Deliberately not a YAML parser: runners have python3 but not pyyaml, and the
|
||||
# file is a flat list. Trivy is the one that must parse it; this only enforces
|
||||
# the repo rule that no exception exists without a written reason.
|
||||
text = open(sys.argv[1]).read()
|
||||
entries = re.findall(r'^\s*-\s+id:\s*(\S+)(.*?)(?=^\s*-\s+id:|\Z)', text, re.M | re.S)
|
||||
if not entries:
|
||||
print("NOTE: no exceptions declared in the ignore file")
|
||||
bad = [i for i, body in entries if not re.search(r'^\s+statement:\s*\S', body, re.M)]
|
||||
if bad:
|
||||
print(f"ERROR: ignore entries without a statement: {', '.join(bad)}", file=sys.stderr)
|
||||
print("Every exception needs a written reason (same rule as ci/excluded-charts.txt).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
# --- 3. Render ---------------------------------------------------------------
|
||||
echo "::group::helm template $CHART ($VALUES_LABEL)"
|
||||
# ${a[@]+"${a[@]}"} - an empty array under `set -u` is an error on bash 3.2
|
||||
# (the macOS default), where this script also runs locally.
|
||||
if ! helm template "$RELEASE" "$ROOT/$CHART" ${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"} > "$RENDERED"; then
|
||||
echo "::endgroup::"
|
||||
echo "ERROR: rendering '$CHART' failed - nothing was scanned." >&2
|
||||
echo "Add the values the chart needs to ci/trivy/values/$CHART.yaml." >&2
|
||||
exit 1
|
||||
fi
|
||||
DOCS="$(grep -c '^kind:' "$RENDERED" || true)"
|
||||
echo "Rendered $DOCS manifests."
|
||||
echo "::endgroup::"
|
||||
|
||||
# --- 4. Scan -----------------------------------------------------------------
|
||||
# Two passes so the split between "actionable" and "covered by an exception" is
|
||||
# made by Trivy's own matching (including the path globs), not re-implemented
|
||||
# here: one pass with the ignore file, one without. A suppressed finding is
|
||||
# dropped from the report entirely - `trivy config` has no --show-suppressed.
|
||||
echo "::group::trivy config $CHART"
|
||||
TRIVY=(trivy config --quiet --disable-telemetry --config-data "$DATA_DIR")
|
||||
"${TRIVY[@]}" -f json -o "$WORK/all.json" "$RENDERED"
|
||||
"${TRIVY[@]}" -f json -o "$WORK/open.json" --ignorefile "$IGNORE_FILE" "$RENDERED"
|
||||
"${TRIVY[@]}" --ignorefile "$IGNORE_FILE" "$RENDERED" || true
|
||||
echo "::endgroup::"
|
||||
|
||||
# --- 5. Report ---------------------------------------------------------------
|
||||
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
export CHART VALUES_LABEL DOCS MODE
|
||||
python3 - "$WORK/all.json" "$WORK/open.json" "$IGNORE_FILE" "$SUMMARY" <<'PY'
|
||||
import json, os, re, sys
|
||||
from collections import Counter
|
||||
|
||||
all_json, open_json, ignore_file, summary_path = sys.argv[1:5]
|
||||
chart = os.environ["CHART"]
|
||||
|
||||
def findings(path):
|
||||
doc = json.load(open(path))
|
||||
out = []
|
||||
for res in doc.get("Results", []):
|
||||
for m in res.get("Misconfigurations", []):
|
||||
if m.get("Status") != "FAIL":
|
||||
continue
|
||||
resource = (m.get("CauseMetadata") or {}).get("Resource") or ""
|
||||
out.append((m["ID"], m.get("Severity", "UNKNOWN"), m.get("Title", ""), resource))
|
||||
return out
|
||||
|
||||
every, still_open = findings(all_json), findings(open_json)
|
||||
open_counter = Counter(f[0] for f in still_open)
|
||||
suppressed = Counter(f[0] for f in every) - open_counter
|
||||
|
||||
# The same ID appears several times, scoped to different charts by `paths`.
|
||||
# Trivy decides what is suppressed; this only picks the matching reason to
|
||||
# print, preferring an entry scoped to this chart over a repo-wide one.
|
||||
statements = {}
|
||||
text = open(ignore_file).read()
|
||||
for ident, body in re.findall(r'^\s*-\s+id:\s*(\S+)(.*?)(?=^\s*-\s+id:|\Z)', text, re.M | re.S):
|
||||
key = ident.strip('"\'').removeprefix("AVD-")
|
||||
st = re.search(r'^\s+statement:\s*(.+?)\s*$', body, re.M)
|
||||
reason = st.group(1).strip('"\'') if st else ""
|
||||
paths = re.search(r'^\s+paths:\s*(.+?)\s*$', body, re.M)
|
||||
if paths:
|
||||
if f"{chart}.yaml" not in paths.group(1):
|
||||
continue # scoped to a different chart
|
||||
statements[key] = reason
|
||||
else:
|
||||
statements.setdefault(key, reason)
|
||||
|
||||
meta = {f[0]: (f[1], f[2]) for f in every}
|
||||
RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
|
||||
def order(item):
|
||||
return (RANK.get(meta[item[0]][0], 9), item[0])
|
||||
|
||||
lines = [f"### `{chart}`", ""]
|
||||
lines.append(
|
||||
f"{len(still_open)} finding(s) to act on, {sum(suppressed.values())} covered by a "
|
||||
f"documented exception - from {os.environ['DOCS']} manifests rendered with "
|
||||
f"`{os.environ['VALUES_LABEL']}`."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if still_open:
|
||||
lines += ["| ID | Severity | Occurrences | Check |", "|---|---|---|---|"]
|
||||
for ident, count in sorted(open_counter.items(), key=order):
|
||||
sev, title = meta[ident]
|
||||
lines.append(f"| [{ident}](https://avd.aquasec.com/misconfig/{ident.lower()}) "
|
||||
f"| {sev} | {count} | {title} |")
|
||||
else:
|
||||
lines.append("No findings outside the documented exceptions.")
|
||||
lines.append("")
|
||||
|
||||
if suppressed:
|
||||
lines += ["<details><summary>Covered by a documented exception "
|
||||
f"({sum(suppressed.values())})</summary>", "",
|
||||
"| ID | Occurrences | Why |", "|---|---|---|"]
|
||||
for ident, count in sorted(suppressed.items(), key=order):
|
||||
lines.append(f"| {ident} | {count} | {statements.get(ident, '(no statement)')} |")
|
||||
lines += ["", "</details>", ""]
|
||||
|
||||
with open(summary_path, "a") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
|
||||
# Annotations, capped: GitHub shows only the first handful, and a wall of them
|
||||
# trains people to scroll past.
|
||||
for ident, count in sorted(open_counter.items(), key=order)[:5]:
|
||||
sev, title = meta[ident]
|
||||
print(f"::warning title=trivy {chart}::{ident} ({sev}, x{count}): {title}")
|
||||
|
||||
print(f"{chart}: {len(still_open)} to act on, {sum(suppressed.values())} excepted")
|
||||
sys.exit(1 if (still_open and os.environ["MODE"] == "gate") else 0)
|
||||
PY
|
||||
Reference in new issue
Block a user