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:
jklein committed 2026-08-23 23:14:12 +02:00
1 parent 1d73beff4c
commit 1ae5e1a7c3
5 files changed
+395 -1

No files matched your search

+59 -1
View File
@@ -9,6 +9,8 @@ jobs:
outputs: outputs:
charts: ${{ steps.detect.outputs.charts }} charts: ${{ steps.detect.outputs.charts }}
any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }} any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }}
scan-charts: ${{ steps.detect.outputs.scan-charts }}
any-scan: ${{ steps.detect.outputs.any-scan }}
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -17,8 +19,9 @@ jobs:
- name: "Detect changed charts" - name: "Detect changed charts"
id: detect id: detect
run: | run: |
CHANGED_FILES=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")
CHARTS="[]" CHARTS="[]"
CHANGED_DIRS=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" | cut -d/ -f1 | sort -u) CHANGED_DIRS=$(echo "$CHANGED_FILES" | cut -d/ -f1 | sort -u)
for DIR in $CHANGED_DIRS; do for DIR in $CHANGED_DIRS; do
if [ -f "$DIR/Chart.yaml" ]; then if [ -f "$DIR/Chart.yaml" ]; then
CHARTS=$(echo "$CHARTS" | jq -c --arg dir "$DIR" '. + [$dir]') CHARTS=$(echo "$CHARTS" | jq -c --arg dir "$DIR" '. + [$dir]')
@@ -33,6 +36,24 @@ jobs:
echo "Changed charts: $CHARTS" echo "Changed charts: $CHARTS"
fi fi
# The Trivy scan normally follows the changed charts. But a PR that
# changes the scanner itself touches no chart at all, and would leave
# the change untested. In that case scan a fixed sample instead, one
# chart per interesting case: a documented deviation (samba), a fully
# hardened one (cyberchef), and one with two workloads
# (matrix-synapse).
SCAN_CHARTS="$CHARTS"
if [ "$CHARTS" = "[]" ] && echo "$CHANGED_FILES" | grep -qE '^(ci/(run-trivy-config\.sh|trivy/)|\.github/workflows/validate-charts\.yml)'; then
SCAN_CHARTS='["samba","cyberchef","matrix-synapse"]'
echo "Scanner changed without a chart change - scanning the sample: $SCAN_CHARTS"
fi
echo "scan-charts=$SCAN_CHARTS" >> "$GITHUB_OUTPUT"
if [ "$SCAN_CHARTS" = "[]" ]; then
echo "any-scan=false" >> "$GITHUB_OUTPUT"
else
echo "any-scan=true" >> "$GITHUB_OUTPUT"
fi
build-chart: build-chart:
name: "Build ${{ matrix.chart }}" name: "Build ${{ matrix.chart }}"
needs: detect-changed-charts needs: detect-changed-charts
@@ -68,6 +89,43 @@ jobs:
echo "Version bump OK: $BASE_VERSION -> $NEW_VERSION" echo "Version bump OK: $BASE_VERSION -> $NEW_VERSION"
fi fi
trivy-config:
name: "Trivy config ${{ matrix.chart }}"
needs: detect-changed-charts
if: needs.detect-changed-charts.outputs.any-scan == 'true'
runs-on: ubuntu-latest
# Reporting only (issue #84): the scan renders each changed chart and lists
# what `trivy config` finds, but it never fails the job - see
# TRIVY_CONFIG_MODE below. Deliberately not a gate yet: the repo has several
# documented deviations, and a check that goes red on those every time is one
# people learn to scroll past. Depends only on detect-changed-charts, so it
# runs beside build-chart and adds no wall-clock time to the pipeline.
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }}
env:
TRIVY_VERSION: v0.74.0
# "report" always exits 0. Switch to "gate" to make a finding that no
# documented exception covers fail the job - the intended step once the
# seccomp gap (#84) is closed.
TRIVY_CONFIG_MODE: report
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Install Helm"
uses: azure/setup-helm@v4
- name: "Install Trivy"
uses: aquasecurity/setup-trivy@v0.3.1
with:
version: ${{ env.TRIVY_VERSION }}
cache: true
# Findings land in the job summary, not only in the log: exceptions with
# their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry
# list from ci/trivy/data/ksv0125.yaml.
- name: "Scan rendered manifests"
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
install-test: install-test:
name: "Install-test ${{ matrix.chart }}" name: "Install-test ${{ matrix.chart }}"
needs: [detect-changed-charts, build-chart] needs: [detect-changed-charts, build-chart]
+204
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
# Trusted registries for AVD-KSV-0125 ("Restrict container images to trusted
# registries"), loaded via `trivy config --config-data ci/trivy/data`.
#
# Deliberately configured instead of ignored. An ignored check also stays quiet
# when an image from a genuinely foreign source shows up; a configured list only
# removes the noise and leaves the check able to say something.
#
# How the check reads this (builtin.kubernetes.KSV0125): it takes the part of
# the image reference before the first "/" and requires it to end with one of
# these entries. Setting the list *replaces* Trivy's built-in default (ACR, ECR
# and GCR domains) - none of which this fleet uses, so nothing is lost.
#
# Note what this does NOT silence: an unqualified Docker Hub image such as
# "apache/tika:3.2.3" puts "apache" in the registry position, which matches
# nothing here and is still reported. That is a real finding, not noise - the
# manifest does not say where the image comes from. It goes away by writing
# such images as "docker.io/apache/tika", which is why docker.io is listed.
ksv0125:
trusted_registries:
- cr.jk-dev.de
- ghcr.io
- docker.io
+95
View File
@@ -0,0 +1,95 @@
# Documented exceptions for `trivy config` (ci/run-trivy-config.sh).
#
# Trivy's native ignore-file schema, so the same file drives the future gate
# (`--ignorefile` + a non-zero exit) without being rewritten.
#
# House rule, same as ci/excluded-charts.txt: no entry without a `statement`.
# An entry without a reason reads as negligence a year from now, an entry with
# one reads as a decision. ci/run-trivy-config.sh refuses to run if a statement
# is missing, and it prints every suppressed finding together with its reason
# instead of hiding it.
#
# `paths` scopes an entry to a single chart: the scan renders each chart to
# <chart>.yaml, so "**/samba.yaml" means "samba only" and every other chart
# keeps being reported for that check.
#
# Not in here on purpose: AVD-KSV-0125 (untrusted registry). Suppressing it
# would also stay silent when an image from a genuinely foreign source appears.
# Instead the trusted-registry list is configured in ci/trivy/data/ksv0125.yaml
# and the check keeps its meaning.
misconfigurations:
# --- repo-wide conventions ------------------------------------------------
- id: AVD-KSV-0011
statement: "No CPU limits, on purpose: CPU is compressible, so a limit throttles where the missing limit merely shares - see README 'Resources (auto-limits)'. The chart helper renders a CPU limit only when one is set explicitly."
- id: AVD-KSV-0020
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101. calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
- id: AVD-KSV-0021
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
- id: AVD-KSV-0110
statement: "Artifact of scanning rendered manifests: no chart pins a namespace (the release does), so Trivy reads every workload as living in 'default'. Nothing to fix in the charts."
# --- samba: deviations forced by the ghcr.io/dockur/samba entrypoint ------
# All four are justified in samba/values.yaml above its securityContext.
- id: AVD-KSV-0012
paths: ["**/samba.yaml"]
statement: "samba starts as root: the entrypoint creates the Unix users from users.conf and smbd impersonates the authenticated share user per session, both of which require uid 0."
- id: AVD-KSV-0014
paths: ["**/samba.yaml"]
statement: "samba writes to its root filesystem at startup (/etc/passwd, /etc/group, the /etc/samba.conf healthcheck symlink, the TDB databases under /var/lib/samba), so readOnlyRootFilesystem cannot be true."
- id: AVD-KSV-0022
paths: ["**/samba.yaml"]
statement: "samba drops ALL and adds back only what a multi-user file server needs: NET_BIND_SERVICE (445/139), SETUID/SETGID (per-session impersonation), CHOWN/FOWNER/DAC_OVERRIDE (share files owned by the users)."
- id: AVD-KSV-0106
paths: ["**/samba.yaml"]
statement: "Same added capability set as AVD-KSV-0022; the check wants NET_BIND_SERVICE alone, which a file server that impersonates users cannot do."
- id: AVD-KSV-0117
paths: ["**/samba.yaml"]
statement: "SMB is served on its registered ports 445 and 139, which are privileged by definition - that is why NET_BIND_SERVICE is added."
# --- calibre-web-automated: deviations forced by the s6-overlay image -----
# All three are justified in calibre-web-automated/values.yaml.
- id: AVD-KSV-0012
paths: ["**/calibre-web-automated.yaml"]
statement: "The CWA image boots via s6-overlay, whose stage-1/2 setup runs as uid 0 before dropping the application to PUID/PGID 1000; the supervisors stay root."
- id: AVD-KSV-0014
paths: ["**/calibre-web-automated.yaml"]
statement: "readOnlyRootFilesystem must stay false: in read-only mode the image disables its PUID/PGID remapping, the runtime uid silently moves from 1000 to the built-in 911 and existing volume data becomes inaccessible."
- id: AVD-KSV-0022
paths: ["**/calibre-web-automated.yaml"]
statement: "ALL dropped except the minimal s6 set verified against v4.0.6: CHOWN/DAC_OVERRIDE/FOWNER/SETGID/SETUID for the ownership fix-up and the root->PUID drop, KILL so the root supervisor can signal the uid-1000 services."
- id: AVD-KSV-0106
paths: ["**/calibre-web-automated.yaml"]
statement: "Same added capability set as AVD-KSV-0022; restricting it to NET_BIND_SERVICE would break the s6 init."
# --- homeassistant: deviations forced by the upstream image ---------------
# All justified in homeassistant/values.yaml above its securityContext.
- id: AVD-KSV-0012
paths: ["**/homeassistant.yaml"]
statement: "The official Home Assistant container boots via s6-overlay as uid 0 and supports no non-root user, so runAsNonRoot/runAsUser are deliberately unset."
- id: AVD-KSV-0118
paths: ["**/homeassistant.yaml"]
statement: "The empty pod securityContext is the deliberate form of the same deviation: the image must start as root. The container context is hardened (no privilege escalation, read-only root filesystem, ALL dropped)."
- id: AVD-KSV-0022
paths: ["**/homeassistant.yaml"]
statement: "ALL dropped, NET_RAW added back because Home Assistant's device discovery (DHCP watcher, ping/arp integrations) uses raw sockets and logs 'Operation not permitted' without it."
- id: AVD-KSV-0106
paths: ["**/homeassistant.yaml"]
statement: "Same NET_RAW addition as AVD-KSV-0022."
- id: AVD-KSV-0119
paths: ["**/homeassistant.yaml"]
statement: "NET_RAW is the capability in question and is added on purpose for device discovery; see AVD-KSV-0022."
+15
View File
@@ -0,0 +1,15 @@
# Render values for the misconfiguration scan only (ci/run-trivy-config.sh).
#
# Used for charts that have no ci/<chart>/test-values.yaml because they are
# excluded from the install-test. template-chart is excluded there for its
# placeholder image, which a static scan never pulls - so it is still scanned:
# it is the scaffold every new chart is copied from, and a finding in it is
# inherited by every chart that follows.
#
# Only the values the chart declares `required` - nothing that would change the
# security posture the scan is meant to look at.
ingress:
domain: scan.example.com
clusterIssuer: letsencrypt
secrets:
xxxSecretRef: op://Vault/scan/placeholder