Files
tessera-ctl/.gitea/scripts/security-scan.sh
T
schalli bf632d90a5 fix(quick-261009-p0m): Review-Befunde WR-01/02/04/05, IN-01/02/03/04 (Abbild-Bau, Semgrep im Container, Zeitbudget, ZAP-Haken)
- WR-01: prisma generate als eigener Schritt mit Pruefung im api-Abbild, Bau scheitert ohne Client; Startprobe als .gitea/scripts/image-start-check.sh versioniert
- WR-02: Semgrep im offiziellen Container, per Digest angepinnt (kein pipx/PyPI mehr)
- WR-04: Zeitbudget 1500 s, Limits je Werkzeug, Ergebniszeile sofort nach jedem Werkzeug
- WR-05: Authorization-Ersetzung der ZAP-Pruefung nur fuer das Ziel
- IN-01: yarn/npm per Platzhalter entfernt und Abwesenheit geprueft (api und web)
- IN-02: Kommentar zu doppelten Kopfzeilen in next.config.ts richtiggestellt
- IN-03: Berichtsordner Modus 700, Zugangsdaten nur als base64 (keine curl-Konfiguration)
- IN-04: flacher Klon wird bei gitleaks als unvollstaendig gemeldet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 20:59:12 +02:00

717 lines
24 KiB
Bash
Executable File

#!/bin/sh
# security-scan.sh -- Sicherheitspruefungen als reiner Bericht (quick-261009-p0m).
#
# Zweck: Die Pipeline (Job `security`) und jeder Entwickler-Rechner pruefen mit
# denselben, fest angepinnten Werkzeugen den eingecheckten Stand von Tessera.
# NUR BERICHT -- das Skript bricht nie ab und endet in jedem Modus mit Exit 0.
# Gefundene Probleme erscheinen als Zeilen `SECURITY-SUMMARY ...` im Protokoll
# und in {Berichte}/summary.txt; die Rohberichte (JSON) liegen im Berichtsordner.
#
# Pruefungen:
# gitleaks gesamte Git-Historie auf Zugangsdaten (--redact, Ausnahmen in .gitleaks.toml)
# pnpm audit bekannte Schwachstellen der Produktions-Abhaengigkeiten
# osv-scanner pnpm-lock.yaml und apps/desktop/src-tauri/Cargo.lock
# semgrep statische Code-Analyse (Ausnahmen in .semgrepignore); laeuft im
# offiziellen Semgrep-Abbild, das per Digest angepinnt ist
# trivy fs Abhaengigkeiten, Dockerfile-Einstellungen, Zugangsdaten im Quellstand
# trivy image die frisch gebauten Abbilder api und web (nur main und Tags v*)
#
# Quellstand: Die Quellpruefungen lesen einen `git archive HEAD`-Export in einem
# Arbeitsordner, gitleaks liest nur die Git-Historie. Damit sieht jeder Lauf genau
# den eingecheckten Stand; nicht eingecheckte oder private Dateien eines
# Arbeitsordners erreichen nie ein Werkzeug, einen Bericht oder ein Artefakt.
#
# Modi:
# install Werkzeuge laden, Pruefsumme (SHA256) gegen das Literal im Skript
# vergleichen, auspacken; Fehler ueberspringen nur das Werkzeug
# run alle Pruefungen ausfuehren, Zusammenfassung schreiben
# all install, dann run (so ruft die Pipeline es auf)
# --print-plan Werkzeuge, Abbilder und Berichtsordner anzeigen (ohne Netz)
#
# Umgebungsvariablen:
# GITHUB_REF Anlass (refs/heads/main -> Abbilder :beta, refs/tags/v* -> :live,
# sonst keine Abbilder); setzt der Runner selbst
# SCAN_IMAGES Abbilder (durch Leerzeichen getrennt) statt des Plans aus GITHUB_REF
# REGISTRY Abbild-Praefix (Standard localhost:3002/schalli/tessera-ctl)
# SECURITY_REPORT_DIR Berichtsordner (Standard security-reports im aktuellen Ordner)
# SECURITY_TOOL_DIR Werkzeugordner (Standard: /opt/hostedtoolcache/tessera-security,
# wenn beschreibbar, sonst ~/.cache/tessera-security)
#
# Lokale Aufrufe:
# sh .gitea/scripts/security-scan.sh --print-plan
# GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh all
# SCAN_IMAGES="tessera-ctl-api:latest" sh .gitea/scripts/security-scan.sh run
#
# Werkzeug-Versionen anheben: neue Version UND die SHA256-Summe aus der offiziellen
# Pruefsummendatei der Veroeffentlichung unten eintragen (docs/ci-cd-setup.md).
#
# Zeitplan: Der Job der Pipeline hat 30 Minuten (timeout-minutes). Das Skript haelt sich
# selbst an ein Gesamtbudget von 25 Minuten (BUDGET_SECONDS = 1500) und gibt die Zeile
# `SECURITY-SUMMARY ...` JE WERKZEUG aus, sobald es fertig ist -- bricht der Runner den
# Job doch einmal ab, stehen die bis dahin fertigen Ergebnisse im Protokoll. Jedes
# Werkzeug hat ein eigenes Zeitlimit (T_*); die Limits ergeben zusammen hoechstens 1500 s,
# und `tmo` kuerzt jedes Limit auf die noch verbleibende Gesamtzeit. Ein Werkzeug, das
# sein Limit reisst, wird als `skipped reason=zeitgrenze` gemeldet, eines, fuer das das
# Gesamtbudget nicht mehr reicht, als `skipped reason=gesamtbudget`.
#
# Dieses Skript kennt kein Secret und gibt keines aus: Zeilen und Zusammenfassung
# enthalten nur Zaehlwerte und Statuswoerter.
# Bewusst kein `set -e`: Jedes Werkzeug laeuft geschuetzt, jeder Fehler ist erwartet.
set -u
GITLEAKS_VERSION=8.30.1
GITLEAKS_URL="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz"
GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
TRIVY_VERSION=0.75.0
TRIVY_URL="https://github.com/aquasecurity/trivy/releases/download/v0.75.0/trivy_0.75.0_Linux-64bit.tar.gz"
TRIVY_SHA256=c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f
OSV_VERSION=2.6.0
OSV_URL="https://github.com/google/osv-scanner/releases/download/v2.6.0/osv-scanner_linux_amd64"
OSV_SHA256=ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
# Semgrep laeuft als Container, angepinnt per Digest (Index des Abbilds semgrep/semgrep:1.180.0,
# 2026-10-09 geholt). Docker prueft beim Laden, dass der Inhalt zum Digest passt; kein
# Paketindex, keine frei aufgeloesten Abhaengigkeiten.
SEMGREP_VERSION=1.180.0
SEMGREP_IMAGE=semgrep/semgrep@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3
PNPM_VERSION=9.15.0
# Zeitplan in Sekunden (siehe Kopf). Summe Installation: 3 x 60 + 150 + 45 = 375;
# Summe Pruefungen: 180 + 90 + 120 + 300 + 180 + 2 x 120 = 1110; zusammen 1485 <= 1500.
BUDGET_SECONDS=1500
T_DOWNLOAD=60
T_SEMGREP_PULL=150
T_COREPACK=45
T_GITLEAKS=180
T_AUDIT=90
T_OSV=120
T_SEMGREP=300
T_TRIVY_FS=180
T_TRIVY_IMAGE=120
START_EPOCH=$(date +%s)
REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"
REF="${GITHUB_REF:-}"
MODE="${1:-}"
# Aufrufordner merken, dann in die Wurzel des Repositorys wechseln (Skript liegt
# in .gitea/scripts/).
START_DIR="$(pwd)"
ROOT="$(cd "$(dirname "$0")/../.." 2>/dev/null && pwd)"
[ -n "$ROOT" ] || ROOT="$START_DIR"
# Berichtsordner absolut machen (relativ zum Aufrufordner).
REPORT="${SECURITY_REPORT_DIR:-security-reports}"
case "$REPORT" in
/*) ;;
*) REPORT="$START_DIR/$REPORT" ;;
esac
# Werkzeugordner.
if [ -n "${SECURITY_TOOL_DIR:-}" ]; then
TOOLDIR="$SECURITY_TOOL_DIR"
elif [ -d /opt/hostedtoolcache ] && [ -w /opt/hostedtoolcache ]; then
TOOLDIR=/opt/hostedtoolcache/tessera-security
else
TOOLDIR="${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/tessera-security"
fi
case "$TOOLDIR" in
/*) ;;
*) TOOLDIR="$START_DIR/$TOOLDIR" ;;
esac
GITLEAKS_BIN="$TOOLDIR/gitleaks-$GITLEAKS_VERSION/gitleaks"
TRIVY_BIN="$TOOLDIR/trivy-$TRIVY_VERSION/trivy"
OSV_BIN="$TOOLDIR/osv-scanner-$OSV_VERSION/osv-scanner"
COREPACK_HOME_DIR="$TOOLDIR/corepack"
TRIVY_CACHE="$TOOLDIR/trivy-cache"
# --- Abbilder je Anlass ------------------------------------------------------
if [ -n "${SCAN_IMAGES:-}" ]; then
IMAGES="$SCAN_IMAGES"
else
case "$REF" in
refs/heads/main) IMAGES="$REGISTRY/api:beta $REGISTRY/web:beta" ;;
refs/tags/v*) IMAGES="$REGISTRY/api:live $REGISTRY/web:live" ;;
*) IMAGES="" ;;
esac
fi
# Name fuer Berichtsdatei und Zusammenfassung: letztes Pfadstueck vor dem Doppelpunkt.
image_name() {
_n="${1##*/}"
printf '%s' "${_n%%:*}"
}
print_plan() {
echo "werkzeug gitleaks $GITLEAKS_VERSION"
echo "werkzeug trivy $TRIVY_VERSION"
echo "werkzeug osv-scanner $OSV_VERSION"
echo "werkzeug semgrep $SEMGREP_VERSION (Abbild $SEMGREP_IMAGE)"
echo "zeitbudget ${BUDGET_SECONDS}s"
echo "werkzeug pnpm $PNPM_VERSION"
echo "werkzeugordner $TOOLDIR"
if [ -n "$IMAGES" ]; then
for _i in $IMAGES; do
echo "scan-image $_i"
done
else
echo "scan-image keine (nur main und Tags v*)"
fi
echo "berichte $REPORT"
}
if [ "$MODE" = "--print-plan" ]; then
print_plan
exit 0
fi
case "$MODE" in
install | run | all) ;;
*)
echo "Aufruf: sh .gitea/scripts/security-scan.sh install|run|all|--print-plan (nur Bericht, Exit 0)"
exit 0
;;
esac
# --- Hilfsfunktionen ---------------------------------------------------------
WORK=""
SEMGREP_CT=""
cleanup() {
[ -n "$SEMGREP_CT" ] && docker rm -f "$SEMGREP_CT" >/dev/null 2>&1
[ -n "$WORK" ] && rm -rf "$WORK"
return 0
}
trap cleanup EXIT
trap 'exit 0' INT TERM
# Laufzeitgrenze je Werkzeug, falls `timeout` vorhanden ist. Das Limit wird auf die noch
# verbleibende Gesamtzeit gekuerzt; ist das Gesamtbudget aufgebraucht, laeuft nichts mehr
# (Rueckgabe 97). Rueckgabe 124 = Limit gerissen (von `timeout`).
BUDGET_RC=97
tmo() {
_secs="$1"
shift
_left=$((START_EPOCH + BUDGET_SECONDS - $(date +%s)))
if [ "$_left" -le 5 ]; then
return "$BUDGET_RC"
fi
[ "$_secs" -gt "$_left" ] && _secs="$_left"
if command -v timeout >/dev/null 2>&1; then
timeout "$_secs" "$@"
else
"$@"
fi
}
skipline() {
echo "SECURITY-SUMMARY $1 skipped reason=$2"
}
# Statuswort je Werkzeug (fuer die Zusammenfassung, falls kein Bericht entsteht).
setstatus() {
[ -n "$WORK" ] && [ -d "$WORK/status" ] && printf '%s\n' "$2" >"$WORK/status/$1"
return 0
}
# Rueckgabewert eines Werkzeugs auswerten: Zeitgrenze oder Gesamtbudget -> Statuswort,
# ein evtl. halbfertiger Bericht wird verworfen. $1 Rueckgabewert, $2 Werkzeug, $3 Bericht.
note_rc() {
case "$1" in
124)
setstatus "$2" zeitgrenze
rm -f "$3"
;;
"$BUDGET_RC")
setstatus "$2" gesamtbudget
rm -f "$3"
;;
esac
return 0
}
sha_ok() {
# $1 Datei, $2 erwartete SHA256
[ -f "$1" ] || return 1
printf '%s %s\n' "$2" "$1" | sha256sum -c - >/dev/null 2>&1
}
# Archiv laden und pruefen: $1 Name, $2 URL, $3 SHA256, $4 Zieldatei.
# Rueckgabe 0 = Datei liegt geprueft vor; sonst Statuswort in FETCH_REASON.
FETCH_REASON=""
fetch_verified() {
if sha_ok "$4" "$3"; then
return 0
fi
rm -f "$4" "$4.part"
if ! curl -fsSL --retry 2 --connect-timeout 20 --max-time "$T_DOWNLOAD" -o "$4.part" "$2" 2>/dev/null; then
rm -f "$4.part"
FETCH_REASON=download
return 1
fi
if ! sha_ok "$4.part" "$3"; then
rm -f "$4.part"
FETCH_REASON=checksum
return 1
fi
mv "$4.part" "$4"
return 0
}
# Binaerwerkzeug aus tar.gz: $1 Name, $2 Version, $3 URL, $4 SHA256, $5 Binaerdatei im Archiv.
install_tar_tool() {
_dir="$TOOLDIR/$1-$2"
_dl="$TOOLDIR/dl"
_archive="$_dl/${3##*/}"
mkdir -p "$_dir" "$_dl" 2>/dev/null || {
skipline "$1" ordner
return 0
}
if ! fetch_verified "$1" "$3" "$4" "$_archive"; then
skipline "$1" "$FETCH_REASON"
return 0
fi
# Bei jedem Lauf frisch aus dem geprueften Archiv auspacken.
rm -f "$_dir/$5"
if ! tar -xzf "$_archive" -C "$_dir" "$5" 2>/dev/null || [ ! -x "$_dir/$5" ]; then
skipline "$1" auspacken
return 0
fi
echo "Werkzeug $1 $2 bereit (Pruefsumme stimmt)"
}
install_osv() {
_dir="$TOOLDIR/osv-scanner-$OSV_VERSION"
_dl="$TOOLDIR/dl"
_file="$_dl/osv-scanner_linux_amd64-$OSV_VERSION"
mkdir -p "$_dir" "$_dl" 2>/dev/null || {
skipline osv-scanner ordner
return 0
}
if ! fetch_verified osv-scanner "$OSV_URL" "$OSV_SHA256" "$_file"; then
skipline osv-scanner "$FETCH_REASON"
return 0
fi
rm -f "$_dir/osv-scanner"
if ! cp "$_file" "$_dir/osv-scanner" 2>/dev/null || ! chmod +x "$_dir/osv-scanner"; then
skipline osv-scanner auspacken
return 0
fi
echo "Werkzeug osv-scanner $OSV_VERSION bereit (Pruefsumme stimmt)"
}
install_semgrep() {
if ! command -v docker >/dev/null 2>&1; then
skipline semgrep kein-docker
return 0
fi
if docker image inspect "$SEMGREP_IMAGE" >/dev/null 2>&1; then
echo "Werkzeug semgrep $SEMGREP_VERSION bereit (Abbild vorhanden, Digest angepinnt)"
return 0
fi
if tmo "$T_SEMGREP_PULL" docker pull -q "$SEMGREP_IMAGE" >/dev/null 2>&1 &&
docker image inspect "$SEMGREP_IMAGE" >/dev/null 2>&1; then
echo "Werkzeug semgrep $SEMGREP_VERSION bereit (Abbild geladen, Digest angepinnt)"
else
skipline semgrep install
fi
}
# pnpm: eine pnpm 9.15.x im PATH, sonst corepack mit angepinnter Version.
PNPM_MODE=""
PNPM_CHECKED=no
detect_pnpm() {
[ "$PNPM_CHECKED" = yes ] && [ -n "$PNPM_MODE" ] && return 0
[ "$PNPM_CHECKED" = yes ] && return 1
PNPM_CHECKED=yes
PNPM_MODE=""
if command -v pnpm >/dev/null 2>&1; then
_v="$(cd "$TOOLDIR" 2>/dev/null && pnpm --version 2>/dev/null)"
case "$_v" in
9.15.*)
PNPM_MODE=path
return 0
;;
esac
fi
if command -v corepack >/dev/null 2>&1; then
_v="$(COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo "$T_COREPACK" corepack "pnpm@$PNPM_VERSION" --version 2>/dev/null)"
if [ "$_v" = "$PNPM_VERSION" ]; then
PNPM_MODE=corepack
return 0
fi
fi
return 1
}
# pnpm im aktuellen Ordner ausfuehren (nach detect_pnpm).
run_pnpm() {
if [ "$PNPM_MODE" = path ]; then
tmo "$T_AUDIT" pnpm "$@"
else
COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo "$T_AUDIT" corepack "pnpm@$PNPM_VERSION" "$@"
fi
}
do_install() {
case "$(uname -m 2>/dev/null)" in
x86_64 | amd64) ;;
*)
for _t in gitleaks trivy osv-scanner; do skipline "$_t" plattform; done
;;
esac
if [ "$(uname -m 2>/dev/null)" = x86_64 ] || [ "$(uname -m 2>/dev/null)" = amd64 ]; then
install_tar_tool gitleaks "$GITLEAKS_VERSION" "$GITLEAKS_URL" "$GITLEAKS_SHA256" gitleaks
install_tar_tool trivy "$TRIVY_VERSION" "$TRIVY_URL" "$TRIVY_SHA256" trivy
install_osv
fi
install_semgrep
if detect_pnpm; then
echo "Werkzeug pnpm $PNPM_VERSION bereit ($PNPM_MODE)"
else
skipline pnpm-audit-prod kein-pnpm
fi
}
# --- Pruefungen --------------------------------------------------------------
# Jede Pruefung gibt ihre Zeile `SECURITY-SUMMARY ...` aus, sobald sie fertig ist
# (report_tool), damit ein Abbruch des ganzen Jobs die fertigen Ergebnisse nicht verschluckt.
scan_gitleaks() {
if [ ! -x "$GITLEAKS_BIN" ]; then
setstatus gitleaks nicht-installiert
report_tool gitleaks
return 0
fi
# Ein flacher Klon enthaelt nur einen Teil der Historie: das Ergebnis waere "sauber",
# ohne dass alle Commits geprueft wurden. Der Lauf wird trotzdem gemacht, aber als
# unvollstaendig gemeldet.
if [ "$(git -C "$ROOT" rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then
setstatus gitleaks flacher-klon
else
setstatus gitleaks fehler
fi
(
cd "$ROOT" &&
tmo "$T_GITLEAKS" "$GITLEAKS_BIN" git --config "$ROOT/.gitleaks.toml" --redact --no-banner \
--exit-code 0 --report-format json --report-path "$REPORT/gitleaks.json" . >/dev/null 2>&1
)
note_rc $? gitleaks "$REPORT/gitleaks.json"
report_tool gitleaks
return 0
}
scan_pnpm_audit() {
if ! detect_pnpm; then
setstatus pnpm-audit-prod kein-pnpm
report_tool pnpm-audit-prod
return 0
fi
setstatus pnpm-audit-prod fehler
(
cd "$WORK/src" && run_pnpm audit --prod --json >"$REPORT/pnpm-audit-prod.json" 2>/dev/null
)
note_rc $? pnpm-audit-prod "$REPORT/pnpm-audit-prod.json"
report_tool pnpm-audit-prod
return 0
}
scan_osv() {
if [ ! -x "$OSV_BIN" ]; then
setstatus osv-scanner nicht-installiert
report_tool osv-scanner
return 0
fi
setstatus osv-scanner fehler
(
cd "$WORK/src" &&
tmo "$T_OSV" "$OSV_BIN" scan source --lockfile pnpm-lock.yaml \
--lockfile apps/desktop/src-tauri/Cargo.lock \
--format json --output "$REPORT/osv-scanner.json" >/dev/null 2>&1
)
note_rc $? osv-scanner "$REPORT/osv-scanner.json"
report_tool osv-scanner
return 0
}
# Semgrep im angepinnten Container: der Quellstand wird mit `docker cp` hinein und der
# Bericht heraus kopiert (kein Ordner-Mount, denn der Pfad der Pipeline liegt im
# Job-Container, nicht auf dem Docker-Rechner).
scan_semgrep() {
if ! command -v docker >/dev/null 2>&1; then
setstatus semgrep kein-docker
report_tool semgrep
return 0
fi
if ! docker image inspect "$SEMGREP_IMAGE" >/dev/null 2>&1; then
setstatus semgrep nicht-installiert
report_tool semgrep
return 0
fi
setstatus semgrep fehler
SEMGREP_CT="tessera-semgrep-$$"
if docker create --name "$SEMGREP_CT" -w /src \
-e SEMGREP_SEND_METRICS=off -e SEMGREP_ENABLE_VERSION_CHECK=0 \
"$SEMGREP_IMAGE" semgrep scan --config p/default --config p/typescript \
--config p/nodejs --config p/secrets --config p/dockerfile \
--metrics=off --quiet --timeout 20 --json --output /tmp/semgrep.json . >/dev/null 2>&1 &&
docker cp "$WORK/src/." "$SEMGREP_CT:/src" >/dev/null 2>&1; then
tmo "$T_SEMGREP" docker start -a "$SEMGREP_CT" >/dev/null 2>&1
_rc=$?
if [ "$_rc" = 124 ] || [ "$_rc" = "$BUDGET_RC" ]; then
note_rc "$_rc" semgrep "$REPORT/semgrep.json"
else
docker cp "$SEMGREP_CT:/tmp/semgrep.json" "$REPORT/semgrep.json" >/dev/null 2>&1
fi
fi
docker rm -f "$SEMGREP_CT" >/dev/null 2>&1
SEMGREP_CT=""
report_tool semgrep
return 0
}
# Trivy: Zugangsdaten-Suche ueberspringt nur den Ordner mit den Testschluesseln
# des Zertifikatsmanagers (Konfiguration entsteht im Arbeitsordner, nicht im Repository).
write_trivy_secret_config() {
cat >"$WORK/trivy-secret.yaml" <<'EOF'
allow-rules:
- id: tessera-zertifikatsmanager-testschluessel
description: Testschluessel des Zertifikatsmanagers (absichtlich im Repository)
path: ^apps/api/src/cert-manager/__fixtures__/
EOF
}
scan_trivy_fs() {
if [ ! -x "$TRIVY_BIN" ]; then
setstatus trivy-fs nicht-installiert
report_tool trivy-fs
return 0
fi
setstatus trivy-fs fehler
write_trivy_secret_config
mkdir -p "$TRIVY_CACHE" 2>/dev/null
(
cd "$WORK/src" &&
tmo "$T_TRIVY_FS" "$TRIVY_BIN" fs --cache-dir "$TRIVY_CACHE" --quiet --no-progress --skip-version-check \
--scanners vuln,misconfig,secret --secret-config "$WORK/trivy-secret.yaml" \
--exit-code 0 --format json --output "$REPORT/trivy-fs.json" . >/dev/null 2>&1
)
note_rc $? trivy-fs "$REPORT/trivy-fs.json"
report_tool trivy-fs
return 0
}
scan_trivy_images() {
if [ -z "$IMAGES" ]; then
emit "SECURITY-SUMMARY trivy-image skipped reason=kein-abbild-fuer-diesen-ref"
return 0
fi
for _img in $IMAGES; do
_name="$(image_name "$_img")"
_tool="trivy-image-$_name"
if [ ! -x "$TRIVY_BIN" ]; then
setstatus "$_tool" nicht-installiert
report_tool "$_tool"
continue
fi
if ! command -v docker >/dev/null 2>&1 || ! docker image inspect "$_img" >/dev/null 2>&1; then
setstatus "$_tool" abbild-fehlt
report_tool "$_tool"
continue
fi
setstatus "$_tool" fehler
mkdir -p "$TRIVY_CACHE" 2>/dev/null
tmo "$T_TRIVY_IMAGE" "$TRIVY_BIN" image --image-src docker --cache-dir "$TRIVY_CACHE" --quiet --no-progress \
--skip-version-check --scanners vuln,secret --exit-code 0 --format json \
--output "$REPORT/$_tool.json" "$_img" >/dev/null 2>&1
note_rc $? "$_tool" "$REPORT/$_tool.json"
report_tool "$_tool"
done
return 0
}
# Zeile ins Protokoll UND (sobald der Berichtsordner steht) nach summary.txt.
emit() {
echo "$1"
[ -n "$WORK" ] && [ -d "$REPORT" ] && echo "$1" >>"$REPORT/summary.txt"
return 0
}
# Auswertung EINES Werkzeugs (nur Zaehlwerte): Hilfsprogramm im Arbeitsordner, damit es
# nach jedem Werkzeug einzeln aufgerufen werden kann.
write_summary_helper() {
cat >"$WORK/summ.py" <<'EOF'
import json, os, sys
rep, st, tool = sys.argv[1], sys.argv[2], sys.argv[3]
def load(name):
try:
with open(os.path.join(rep, name), encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def status():
try:
with open(os.path.join(st, tool), encoding="utf-8") as f:
return f.read().split()[0]
except Exception:
return "kein-bericht"
def skip():
return "SECURITY-SUMMARY %s skipped reason=%s" % (tool, status())
def sev_counts(results, key="Vulnerabilities"):
c = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
for r in results or []:
for v in r.get(key) or []:
s = str(v.get("Severity", "")).upper()
if s in c:
c[s] += 1
return c
line = None
if tool == "gitleaks":
d = load("gitleaks.json")
if isinstance(d, list):
line = "SECURITY-SUMMARY gitleaks findings=%d" % len(d)
if status() == "flacher-klon":
line += " unvollstaendig (flacher Klon: nur die sichtbaren Commits geprueft)"
elif tool == "pnpm-audit-prod":
d = load("pnpm-audit-prod.json")
m = d.get("metadata", {}).get("vulnerabilities") if isinstance(d, dict) else None
if isinstance(m, dict):
line = (
"SECURITY-SUMMARY pnpm-audit-prod critical=%d high=%d moderate=%d low=%d"
% (m.get("critical", 0), m.get("high", 0), m.get("moderate", 0), m.get("low", 0))
)
elif tool == "osv-scanner":
d = load("osv-scanner.json")
if isinstance(d, dict) and isinstance(d.get("results"), list):
pk = set()
for r in d["results"]:
for p in r.get("packages") or []:
if p.get("vulnerabilities"):
x = p.get("package") or {}
pk.add((x.get("ecosystem"), x.get("name"), x.get("version")))
line = "SECURITY-SUMMARY osv-scanner packages=%d" % len(pk)
elif tool == "semgrep":
d = load("semgrep.json")
if isinstance(d, dict) and isinstance(d.get("results"), list):
line = "SECURITY-SUMMARY semgrep findings=%d errors=%d" % (
len(d["results"]),
len(d.get("errors") or []),
)
elif tool == "trivy-fs":
d = load("trivy-fs.json")
if isinstance(d, dict):
res = d.get("Results") or []
c = sev_counts(res)
mis = sum(len(r.get("Misconfigurations") or []) for r in res)
sec = sum(len(r.get("Secrets") or []) for r in res)
line = (
"SECURITY-SUMMARY trivy-fs critical=%d high=%d medium=%d low=%d misconfig=%d secrets=%d"
% (c["CRITICAL"], c["HIGH"], c["MEDIUM"], c["LOW"], mis, sec)
)
elif tool.startswith("trivy-image-"):
d = load(tool + ".json")
if isinstance(d, dict):
c = sev_counts(d.get("Results"))
line = "SECURITY-SUMMARY %s critical=%d high=%d medium=%d low=%d" % (
tool,
c["CRITICAL"],
c["HIGH"],
c["MEDIUM"],
c["LOW"],
)
print(line or skip())
EOF
}
report_tool() {
if [ -n "$WORK" ] && [ -f "$WORK/summ.py" ] && command -v python3 >/dev/null 2>&1; then
_line="$(python3 -I "$WORK/summ.py" "$REPORT" "$WORK/status" "$1" 2>/dev/null)"
[ -n "$_line" ] || _line="SECURITY-SUMMARY $1 skipped reason=kein-bericht"
emit "$_line"
else
emit "SECURITY-SUMMARY $1 skipped reason=kein-python"
fi
return 0
}
finish_line() {
emit "SECURITY-SUMMARY dauer=$(($(date +%s) - START_EPOCH))s von ${BUDGET_SECONDS}s"
emit "SECURITY-SUMMARY fertig (nur Bericht, Exit 0)"
}
do_run() {
if ! mkdir -p "$REPORT" 2>/dev/null; then
skipline berichtsordner nicht-beschreibbar
echo "SECURITY-SUMMARY fertig (nur Bericht, Exit 0)"
return 0
fi
# Alte Berichte eines frueheren Laufs entfernen, damit keine Zahlen veraltet wirken.
rm -f "$REPORT"/gitleaks.json "$REPORT"/pnpm-audit-prod.json "$REPORT"/osv-scanner.json \
"$REPORT"/semgrep.json "$REPORT"/trivy-fs.json "$REPORT"/trivy-image-*.json "$REPORT"/summary.txt
WORK="$(mktemp -d 2>/dev/null)" || WORK=""
if [ -z "$WORK" ]; then
skipline arbeitsordner nicht-anlegbar
echo "SECURITY-SUMMARY fertig (nur Bericht, Exit 0)"
return 0
fi
mkdir -p "$WORK/src" "$WORK/status"
write_summary_helper
# git darf in einem Ordner arbeiten, der einem anderen Benutzer gehoert (CI-Container
# als root, lokaler Klon als Entwickler).
_n="${GIT_CONFIG_COUNT:-0}"
eval "export GIT_CONFIG_KEY_$_n=safe.directory GIT_CONFIG_VALUE_$_n=\"\$ROOT\""
export GIT_CONFIG_COUNT=$((_n + 1))
EXPORT_OK=no
if (cd "$ROOT" && git archive HEAD) 2>/dev/null | tar -x -C "$WORK/src" 2>/dev/null; then
EXPORT_OK=yes
fi
scan_gitleaks
if [ "$EXPORT_OK" = yes ]; then
scan_pnpm_audit
scan_osv
scan_semgrep
scan_trivy_fs
else
for _t in pnpm-audit-prod osv-scanner semgrep trivy-fs; do
setstatus "$_t" export
report_tool "$_t"
done
fi
scan_trivy_images
# Layer-Zwischenspeicher von Trivy waechst stark; die Datenbank bleibt.
rm -rf "$TRIVY_CACHE/fanal" 2>/dev/null
finish_line
echo "Berichte: $REPORT (in der Pipeline als Artefakt sicherheitsberichte)"
return 0
}
case "$MODE" in
install) do_install ;;
run) do_run ;;
all)
do_install
do_run
;;
esac
exit 0