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>
This commit is contained in:
2026-10-09 20:59:12 +02:00
parent b085889657
commit bf632d90a5
7 changed files with 413 additions and 148 deletions
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# image-start-check.sh -- Startprobe fuer das Server-Abbild (quick-261009-p0m).
#
# Beweist, dass das fertig gebaute api-Abbild (ohne Entwicklungswerkzeuge) gegen eine
# FRISCHE, leere Datenbank alle Migrationen anwendet und die API startet. Das faengt
# genau den Fehler ab, den ein Bau allein nicht zeigt: ein Abbild, dem der Prisma-Client
# oder ein Laufzeitpaket fehlt, wird gebaut und veroeffentlicht, stirbt aber beim Start.
#
# Aufruf (aus dem Repository-Wurzelverzeichnis, Docker noetig):
# bash .gitea/scripts/image-start-check.sh # lokales Abbild tessera-ctl-api:latest
# bash .gitea/scripts/image-start-check.sh localhost:3002/schalli/tessera-ctl/api:beta
#
# Es startet ein kurzlebiges Postgres 16 und das Abbild in einem eigenen Docker-Netz, mit
# reinen Testwerten (keine echten Zugangsdaten, kein Zugriff auf Tessera oder alpha).
# Alles Angelegte wird beim Beenden wieder entfernt. Bei Erfolg endet die Ausgabe mit
# "frische-datenbank ok" und Exit 0, sonst mit einer Fehlermeldung, dem Ende des
# API-Protokolls und Exit 1. Wird nicht automatisch von der Pipeline aufgerufen; es
# gehoert in die Freigabe-Checkliste (docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md).
set -u
IMAGE="${1:-tessera-ctl-api:latest}"
ID="start-check-$$"
NET="$ID-net"
DB="$ID-db"
API="$ID-api"
LOG="$(mktemp)"
cleanup() {
docker rm -f "$API" "$DB" >/dev/null 2>&1
docker network rm "$NET" >/dev/null 2>&1
rm -f "$LOG"
}
trap cleanup EXIT INT TERM HUP PIPE
fail() {
echo "FEHLER: $*" >&2
echo "--- Protokoll der API (letzte 40 Zeilen) ---" >&2
docker logs "$API" 2>&1 | tail -40 >&2
exit 1
}
docker network create "$NET" >/dev/null || { echo "FEHLER: Netz nicht anlegbar" >&2; exit 1; }
docker run -d --name "$DB" --network "$NET" \
-e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera_test -e POSTGRES_DB=tessera \
postgres:16-alpine >/dev/null || { echo "FEHLER: Datenbank startet nicht" >&2; exit 1; }
i=0
until docker exec "$DB" pg_isready -U tessera -d tessera >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 60 ] && { echo "FEHLER: Datenbank wird nicht bereit" >&2; exit 1; }
sleep 1
done
# pg_isready meldet schon waehrend der Einrichtung "bereit"; kurz warten, dann
# eine echte Abfrage (die Einrichtung startet den Server einmal neu).
sleep 3
i=0
until docker exec "$DB" psql -U tessera -d tessera -tAc 'select 1' >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 30 ] && { echo "FEHLER: Datenbank antwortet nicht" >&2; exit 1; }
sleep 1
done
KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
docker run -d --name "$API" --network "$NET" \
-e "DATABASE_URL=postgresql://tessera:tessera_test@$DB:5432/tessera" \
-e "TESSERA_ENCRYPTION_KEY=$KEY" \
-e "JWT_SECRET=fresh-db-test-jwt-secret" \
-e TESSERA_ADMIN_USER=admin -e TESSERA_ADMIN_EMAIL=admin@tessera.local \
-e TESSERA_ADMIN_PASSWORD=fresh-db-test-pw -e TESSERA_FORCE_CHANGE=false \
"$IMAGE" >/dev/null || { echo "FEHLER: API startet nicht" >&2; exit 1; }
i=0
while :; do
docker logs "$API" >"$LOG" 2>&1
grep -q "Tessera API running" "$LOG" && break
[ "$(docker inspect -f '{{.State.Running}}' "$API" 2>/dev/null)" = "true" ] || fail "Container ist beendet, bevor die Startzeile kam"
i=$((i + 1))
[ "$i" -gt 120 ] && fail "keine Startzeile nach 120 Sekunden"
sleep 1
done
grep -qi "successfully applied" "$LOG" || fail "Prisma meldet nicht, dass die Migrationen angewendet wurden"
N="$(docker exec "$DB" psql -U tessera -d tessera -tAc "select count(*) from _prisma_migrations where finished_at is not null")"
[ "${N:-0}" -ge 1 ] || fail "keine abgeschlossene Migration in der Datenbank"
echo "migrationen=$N"
echo "frische-datenbank ok"
+199 -93
View File
@@ -11,7 +11,8 @@
# 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)
# 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*)
#
@@ -44,6 +45,15 @@
# 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.
@@ -62,9 +72,27 @@ 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:-}"
@@ -98,9 +126,6 @@ esac
GITLEAKS_BIN="$TOOLDIR/gitleaks-$GITLEAKS_VERSION/gitleaks"
TRIVY_BIN="$TOOLDIR/trivy-$TRIVY_VERSION/trivy"
OSV_BIN="$TOOLDIR/osv-scanner-$OSV_VERSION/osv-scanner"
PIPX_HOME_DIR="$TOOLDIR/pipx"
PIPX_BIN_PATH="$TOOLDIR/pipx-bin"
SEMGREP_BIN="$PIPX_BIN_PATH/semgrep"
COREPACK_HOME_DIR="$TOOLDIR/corepack"
TRIVY_CACHE="$TOOLDIR/trivy-cache"
@@ -125,7 +150,8 @@ print_plan() {
echo "werkzeug gitleaks $GITLEAKS_VERSION"
echo "werkzeug trivy $TRIVY_VERSION"
echo "werkzeug osv-scanner $OSV_VERSION"
echo "werkzeug semgrep $SEMGREP_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
@@ -153,17 +179,27 @@ 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.
# 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
@@ -181,6 +217,22 @@ setstatus() {
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
@@ -195,7 +247,7 @@ fetch_verified() {
return 0
fi
rm -f "$4" "$4.part"
if ! curl -fsSL --retry 2 --connect-timeout 20 --max-time 600 -o "$4.part" "$2" 2>/dev/null; then
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
@@ -252,18 +304,17 @@ install_osv() {
}
install_semgrep() {
if [ -x "$SEMGREP_BIN" ] && "$SEMGREP_BIN" --version 2>/dev/null | grep -q "^$SEMGREP_VERSION"; then
echo "Werkzeug semgrep $SEMGREP_VERSION bereit (vorhanden)"
if ! command -v docker >/dev/null 2>&1; then
skipline semgrep kein-docker
return 0
fi
if ! command -v pipx >/dev/null 2>&1; then
skipline semgrep kein-pipx
if docker image inspect "$SEMGREP_IMAGE" >/dev/null 2>&1; then
echo "Werkzeug semgrep $SEMGREP_VERSION bereit (Abbild vorhanden, Digest angepinnt)"
return 0
fi
mkdir -p "$PIPX_HOME_DIR" "$PIPX_BIN_PATH" 2>/dev/null
if PIPX_HOME="$PIPX_HOME_DIR" PIPX_BIN_DIR="$PIPX_BIN_PATH" tmo 600 pipx install --force "semgrep==$SEMGREP_VERSION" >/dev/null 2>&1 &&
"$SEMGREP_BIN" --version 2>/dev/null | grep -q "^$SEMGREP_VERSION"; then
echo "Werkzeug semgrep $SEMGREP_VERSION bereit"
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
@@ -271,7 +322,11 @@ install_semgrep() {
# 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)"
@@ -283,7 +338,7 @@ detect_pnpm() {
esac
fi
if command -v corepack >/dev/null 2>&1; then
_v="$(COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo 120 corepack "pnpm@$PNPM_VERSION" --version 2>/dev/null)"
_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
@@ -295,9 +350,9 @@ detect_pnpm() {
# pnpm im aktuellen Ordner ausfuehren (nach detect_pnpm).
run_pnpm() {
if [ "$PNPM_MODE" = path ]; then
tmo 300 pnpm "$@"
tmo "$T_AUDIT" pnpm "$@"
else
COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo 300 corepack "pnpm@$PNPM_VERSION" "$@"
COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo "$T_AUDIT" corepack "pnpm@$PNPM_VERSION" "$@"
fi
}
@@ -322,60 +377,98 @@ do_install() {
}
# --- 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 900 "$GITLEAKS_BIN" git --config "$ROOT/.gitleaks.toml" --redact --no-banner \
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 600 "$OSV_BIN" scan source --lockfile pnpm-lock.yaml \
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 [ ! -x "$SEMGREP_BIN" ]; then
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
(
cd "$WORK/src" &&
SEMGREP_SEND_METRICS=off SEMGREP_ENABLE_VERSION_CHECK=0 \
tmo 1500 "$SEMGREP_BIN" scan --config p/default --config p/typescript \
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 "$REPORT/semgrep.json" . >/dev/null 2>&1
)
--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
}
@@ -393,6 +486,7 @@ 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
@@ -400,46 +494,58 @@ scan_trivy_fs() {
mkdir -p "$TRIVY_CACHE" 2>/dev/null
(
cd "$WORK/src" &&
tmo 900 "$TRIVY_BIN" fs --cache-dir "$TRIVY_CACHE" --quiet --no-progress --skip-version-check \
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() {
[ -n "$IMAGES" ] || return 0
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 "trivy-image-$_name" nicht-installiert
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 "trivy-image-$_name" abbild-fehlt
setstatus "$_tool" abbild-fehlt
report_tool "$_tool"
continue
fi
setstatus "trivy-image-$_name" fehler
setstatus "$_tool" fehler
mkdir -p "$TRIVY_CACHE" 2>/dev/null
tmo 900 "$TRIVY_BIN" image --image-src docker --cache-dir "$TRIVY_CACHE" --quiet --no-progress \
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/trivy-image-$_name.json" "$_img" >/dev/null 2>&1
--output "$REPORT/$_tool.json" "$_img" >/dev/null 2>&1
note_rc $? "$_tool" "$REPORT/$_tool.json"
report_tool "$_tool"
done
return 0
}
# Zusammenfassung: nur Zaehlwerte, geschrieben ins Protokoll und nach summary.txt.
summarize() {
_imgnames=""
for _img in $IMAGES; do
_imgnames="$_imgnames $(image_name "$_img")"
done
if command -v python3 >/dev/null 2>&1; then
python3 -I -c '
# 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, names = sys.argv[1], sys.argv[2], sys.argv[3].split()
out = []
rep, st, tool = sys.argv[1], sys.argv[2], sys.argv[3]
def load(name):
@@ -450,13 +556,16 @@ def load(name):
return None
def skip(tool):
def status():
try:
with open(os.path.join(st, tool), encoding="utf-8") as f:
reason = f.read().split()[0]
return f.read().split()[0]
except Exception:
reason = "kein-bericht"
out.append("SECURITY-SUMMARY %s skipped reason=%s" % (tool, reason))
return "kein-bericht"
def skip():
return "SECURITY-SUMMARY %s skipped reason=%s" % (tool, status())
def sev_counts(results, key="Vulnerabilities"):
@@ -469,22 +578,22 @@ def sev_counts(results, key="Vulnerabilities"):
return c
line = None
if tool == "gitleaks":
d = load("gitleaks.json")
if isinstance(d, list):
out.append("SECURITY-SUMMARY gitleaks findings=%d" % len(d))
else:
skip("gitleaks")
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):
out.append(
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))
)
else:
skip("pnpm-audit-prod")
elif tool == "osv-scanner":
d = load("osv-scanner.json")
if isinstance(d, dict) and isinstance(d.get("results"), list):
pk = set()
@@ -493,63 +602,56 @@ if isinstance(d, dict) and isinstance(d.get("results"), list):
if p.get("vulnerabilities"):
x = p.get("package") or {}
pk.add((x.get("ecosystem"), x.get("name"), x.get("version")))
out.append("SECURITY-SUMMARY osv-scanner packages=%d" % len(pk))
else:
skip("osv-scanner")
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):
out.append(
"SECURITY-SUMMARY semgrep findings=%d errors=%d"
% (len(d["results"]), len(d.get("errors") or []))
line = "SECURITY-SUMMARY semgrep findings=%d errors=%d" % (
len(d["results"]),
len(d.get("errors") or []),
)
else:
skip("semgrep")
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)
out.append(
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)
)
else:
skip("trivy-fs")
if names:
for n in names:
tool = "trivy-image-" + n
elif tool.startswith("trivy-image-"):
d = load(tool + ".json")
if isinstance(d, dict):
c = sev_counts(d.get("Results"))
out.append(
"SECURITY-SUMMARY %s critical=%d high=%d medium=%d low=%d"
% (tool, c["CRITICAL"], c["HIGH"], c["MEDIUM"], c["LOW"])
line = "SECURITY-SUMMARY %s critical=%d high=%d medium=%d low=%d" % (
tool,
c["CRITICAL"],
c["HIGH"],
c["MEDIUM"],
c["LOW"],
)
else:
skip(tool)
else:
out.append("SECURITY-SUMMARY trivy-image skipped reason=kein-abbild-fuer-diesen-ref")
print(line or skip())
EOF
}
out.append("SECURITY-SUMMARY fertig (nur Bericht, Exit 0)")
text = "\n".join(out) + "\n"
try:
with open(os.path.join(rep, "summary.txt"), "w", encoding="utf-8") as f:
f.write(text)
except Exception:
pass
sys.stdout.write(text)
' "$REPORT" "$WORK/status" "$_imgnames" 2>/dev/null
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
skipline summary kein-python
echo "SECURITY-SUMMARY fertig (nur Bericht, Exit 0)"
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
@@ -567,6 +669,7 @@ do_run() {
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).
@@ -586,14 +689,17 @@ do_run() {
scan_semgrep
scan_trivy_fs
else
for _t in pnpm-audit-prod osv-scanner semgrep trivy-fs; do setstatus "$_t" export; done
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
summarize
finish_line
echo "Berichte: $REPORT (in der Pipeline als Artefakt sicherheitsberichte)"
return 0
}
+53 -13
View File
@@ -28,7 +28,11 @@
# Entwicklungsrechner aus eine Anmeldung (401) verlangt. Der
# Kopf geht ueber eine temporaere Umgebungsdatei (Modus 0600,
# wird beim Beenden entfernt) in den Container, nie auf eine
# Befehlszeile und in keine Ausgabe.
# Befehlszeile und in keine Ausgabe. Der Haken schickt ihn NUR an
# das Ziel (ZAP_TARGET), nie an andere Adressen. Sonderzeichen im
# Passwort (" oder \) sind erlaubt.
#
# Berichtsordner: Modus 700 (nur der ZAP-Benutzer mit Kennung 1000 schreibt, niemand sonst liest).
#
# Hinweis: Wird die Anmeldedatei benutzt, enthaelt der Bericht die gesendeten Kopfzeilen
# und damit die Zugangsdaten. Dann den Berichtsordner nicht weitergeben.
@@ -64,10 +68,18 @@ if [ "${1:-}" = "--print-plan" ]; then
fi
TMP_FILES=""
RUN_LOG=""
# Entfernt die temporaeren Dateien mit dem Kopf (Zugangsdaten).
cleanup() {
for f in $TMP_FILES; do rm -f "$f"; done
TMP_FILES=""
}
trap cleanup EXIT HUP INT TERM PIPE
# Beim Beenden (auch bei Abbruch) zusaetzlich das Zwischenprotokoll entfernen.
cleanup_all() {
cleanup
[ -n "$RUN_LOG" ] && rm -f "$RUN_LOG"
}
trap cleanup_all EXIT HUP INT TERM PIPE
echo "ZAP-Grundpruefung gegen $TARGET (nur passiv, keine Formulare, keine Angriffe)"
@@ -84,19 +96,22 @@ case "$CODE" in
echo "benutzer:passwort, und rufen Sie das Skript mit ZAP_BASIC_AUTH_FILE=Pfad auf." >&2
exit 4
fi
CRED=$(head -n 1 "$ZAP_BASIC_AUTH_FILE" | tr -d '\r\n')
CURL_CFG=$(mktemp) || exit 4
# Die Zugangsdaten gehen nur als base64-Text weiter (enthaelt keine Anfuehrungszeichen,
# Rueckstriche oder Leerzeichen), also nie durch einen Parser, der Sonderzeichen deutet:
# weder curl-Konfiguration noch Befehlszeile. Passwoerter mit " oder \ sind unkritisch.
B64=$(head -n 1 "$ZAP_BASIC_AUTH_FILE" | tr -d '\r\n' | base64 | tr -d '\n')
HDR_FILE=$(mktemp) || exit 4
ENV_FILE=$(mktemp) || exit 4
TMP_FILES="$CURL_CFG $ENV_FILE"
chmod 600 "$CURL_CFG" "$ENV_FILE"
printf 'user = "%s"\n' "$CRED" > "$CURL_CFG"
CODE2=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -K "$CURL_CFG" "$TARGET/login" 2>/dev/null) || CODE2=000
TMP_FILES="$HDR_FILE $ENV_FILE"
chmod 600 "$HDR_FILE" "$ENV_FILE"
printf 'Authorization: Basic %s\n' "$B64" > "$HDR_FILE"
printf 'ZAP_BASIC_AUTH=Basic %s\n' "$B64" > "$ENV_FILE"
B64=""
CODE2=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -H "@$HDR_FILE" "$TARGET/login" 2>/dev/null) || CODE2=000
if [ "$CODE2" != "200" ]; then
echo "Mit den hinterlegten Zugangsdaten antwortet das Ziel mit Status $CODE2, Abbruch." >&2
exit 4
fi
printf 'ZAP_BASIC_AUTH=Basic %s\n' "$(printf '%s' "$CRED" | base64 | tr -d '\n')" > "$ENV_FILE"
CRED=""
;;
*)
echo "Das Ziel antwortet auf /login nicht mit Status 200 (Status $CODE), Abbruch." >&2
@@ -106,9 +121,24 @@ case "$CODE" in
esac
# --- Berichtsordner: der Container laeuft als Benutzer zap (Kennung 1000) ---------
# Nur dieser eine Benutzer darf schreiben (Modus 700), kein anderer Benutzer des Rechners
# darf lesen -- der Bericht kann die Zugangsdaten enthalten. Hat Ihr Konto die Kennung
# 1000 (der Normalfall), genuegt chmod; sonst uebergibt ein kurzer Container-Aufruf den
# Ordner an die Kennung 1000 und danach wieder an Sie zurueck.
mkdir -p "$REPORT_DIR" || exit 3
REPORT_DIR=$(cd "$REPORT_DIR" && pwd)
chmod 777 "$REPORT_DIR"
chmod 700 "$REPORT_DIR"
ME_UID=$(id -u)
ME_GID=$(id -g)
HANDOVER=no
if [ "$ME_UID" != "1000" ]; then
if docker run --rm --user 0:0 -v "$REPORT_DIR:/zap/wrk:rw" "$IMAGE" chown 1000:1000 /zap/wrk >/dev/null 2>&1; then
HANDOVER=yes
else
echo "Der Berichtsordner konnte nicht an den ZAP-Benutzer (Kennung 1000) uebergeben werden." >&2
exit 3
fi
fi
set -- docker run --rm
[ -n "${ZAP_DOCKER_NETWORK:-}" ] && set -- "$@" --network "$ZAP_DOCKER_NETWORK"
@@ -119,10 +149,20 @@ set -- "$@" -v "$REPORT_DIR:/zap/wrk:rw" -v "$HOOK_FILE:$HOOK_IN_CONTAINER:ro" "
# Exit-Codes von ZAP (0 ok, 1 Fehler, 2 Warnung, 3 Fehlstart) sind hier nicht
# entscheidend: es zaehlt, ob der Bericht entstanden ist.
"$@" > "$REPORT_DIR/zap-lauf.log" 2>&1
# Das Protokoll geht zuerst in eine eigene Datei: der Berichtsordner gehoert waehrend des
# Laufs eventuell der Kennung 1000, nicht Ihrem Konto.
RUN_LOG=$(mktemp) || exit 3
"$@" > "$RUN_LOG" 2>&1
# Die Umgebungsdatei mit dem Kopf wird nur fuer den Lauf gebraucht: sofort entfernen.
cleanup
TMP_FILES=""
if [ "$HANDOVER" = yes ]; then
# Ordner samt Inhalt zurueck an das eigene Konto, damit Sie den Bericht lesen koennen.
docker run --rm --user 0:0 -v "$REPORT_DIR:/zap/wrk:rw" "$IMAGE" chown -R "$ME_UID:$ME_GID" /zap/wrk >/dev/null 2>&1 ||
echo "Hinweis: Der Berichtsordner gehoert noch der Kennung 1000; mit sudo chown -R $ME_UID:$ME_GID uebernehmen." >&2
fi
cat "$RUN_LOG" > "$REPORT_DIR/zap-lauf.log" 2>/dev/null
rm -f "$RUN_LOG"
RUN_LOG=""
echo "ZAP-Lauf beendet (Protokoll: $REPORT_DIR/zap-lauf.log)"
if [ ! -s "$REPORT_DIR/zap.json" ]; then
+9 -1
View File
@@ -8,10 +8,13 @@ Aufgaben:
die Pruefung rein passiv: es werden nur Seiten abgerufen (GET), nie Daten
an die Anwendung geschickt (kein POST).
2. Nur wenn die Umgebungsvariable ZAP_BASIC_AUTH gesetzt ist (Anmeldedatei-Weg des
Skripts), wird ein Ersetzungsregel fuer den Kopf Authorization eingetragen.
Skripts), wird eine Ersetzungsregel fuer den Kopf Authorization eingetragen --
beschraenkt auf Adressen des Ziels (Schema, Host, Port). Andere Hosts (Weiterleitungen,
eingebundene Fremdseiten, Zusatzdienste von ZAP) bekommen den Kopf nie.
Der Wert steht nie in einem Aufruf oder einer Ausgabe.
"""
import os
import re
def zap_started(zap, target):
@@ -21,6 +24,10 @@ def zap_started(zap, target):
auth = os.environ.get("ZAP_BASIC_AUTH")
if auth:
# Die Regel gilt nur fuer URLs, die mit der Ziel-Adresse beginnen (Java-Regex, die
# ganze URL muss passen). Danach darf nur ein Pfad, eine Abfrage oder ein Anker
# folgen -- so passt auch http://host:30001 nicht auf das Ziel http://host:3000.
url_regex = re.escape(target.rstrip("/")) + r"(?:[/?#].*)?"
zap.replacer.add_rule(
description="basicauth",
enabled=True,
@@ -29,4 +36,5 @@ def zap_started(zap, target):
matchregex=False,
replacement=auth,
initiators="",
url=url_regex,
)
+15 -2
View File
@@ -37,6 +37,11 @@ COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
COPY apps/api/prisma ./apps/api/prisma/
RUN pnpm install --frozen-lockfile --prod --filter=@tessera/api...
# Der Prisma-Client entsteht im postinstall von apps/api, der Fehler verschluckt
# (`prisma generate || true`). Deshalb hier noch einmal ausdruecklich erzeugen und pruefen:
# fehlt der Client, scheitert der Bau -- statt eines Abbilds, das erst beim Start stirbt.
RUN apps/api/node_modules/.bin/prisma generate --schema apps/api/prisma/schema.prisma \
&& find node_modules/.pnpm -path '*/.prisma/client/index.js' | grep -q .
# quick-261009-p0m: Laufzeitstufe direkt vom Node-Abbild (ohne das vorbereitete pnpm
# der Stufe base). Die Paketverwaltungen des Node-Abbilds (npm, npx, corepack, yarn)
@@ -49,8 +54,16 @@ ARG APP_CHANNEL
ARG APP_COMMIT
ARG APP_BUILD_TIME
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn-v1.22.22 \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
# Die Ordner-/Dateinamen werden mit Platzhaltern gesucht (yarn-v1.22.22 wechselt mit dem
# Node-Abbild); die letzte Zeile laesst den Bau scheitern, falls eines der Werkzeuge
# bleibt -- sonst verpufft die Absicherung still bei einem neuen Basisabbild.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn* \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn* \
&& for c in npm npx corepack yarn yarnpkg; do \
if command -v "$c" >/dev/null 2>&1; then echo "FEHLER: $c ist noch im Abbild" >&2; exit 1; fi; \
done \
&& [ -z "$(ls -d /opt/yarn* /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack 2>/dev/null)" ] \
&& node --version
RUN addgroup --system --gid 1001 nestjs && \
adduser --system --uid 1001 nestjs && \
mkdir -p /app/user-files && \
+10 -2
View File
@@ -44,8 +44,16 @@ ARG APP_BUILD_TIME
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
# quick-261009-p0m: Paketverwaltungen des Node-Abbilds (npm, npx, corepack, yarn) entfernen;
# der Web-Server braucht nur `node`.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn-v1.22.22 \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
# Die Ordner-/Dateinamen werden mit Platzhaltern gesucht (yarn-v1.22.22 wechselt mit dem
# Node-Abbild); die letzte Zeile laesst den Bau scheitern, falls eines der Werkzeuge
# bleibt -- sonst verpufft die Absicherung still bei einem neuen Basisabbild.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn* \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn* \
&& for c in npm npx corepack yarn yarnpkg; do \
if command -v "$c" >/dev/null 2>&1; then echo "FEHLER: $c ist noch im Abbild" >&2; exit 1; fi; \
done \
&& [ -z "$(ls -d /opt/yarn* /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack 2>/dev/null)" ] \
&& node --version
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/public ./apps/web/public
+7 -3
View File
@@ -29,9 +29,13 @@ function readChangelog(): string {
}
// Schutz-Kopfzeilen der Anwendung (quick-261009-p0m, aus dem ersten ZAP-Lauf). Sie gelten fuer
// alle Pfade und stehen hier, damit sie auch ohne vorgeschalteten Proxy gesendet werden
// (doppelt gesichert; setzt der Proxy dieselben Kopfzeilen, gewinnt keine Seite etwas oder
// verliert etwas). Absichtlich KEINE vollstaendige Content-Security-Policy -- sie braucht eine
// alle Pfade und stehen hier, damit sie auch ohne vorgeschalteten Proxy gesendet werden.
// Setzt der Proxy dieselben Kopfzeilen mit gleichem Wert, aendert sich nichts. Setzt er sie mit
// ANDEREM Wert (etwa eine eigene Content-Security-Policy oder X-Frame-Options: DENY), gelten
// beide nebeneinander und der Browser nimmt die strengere -- das kann Tessera-Seiten sperren,
// die sich selbst einrahmen. Ob der Proxy welche sendet, ist mit dem ersten ZAP-Lauf gegen die
// oeffentliche Adresse zu pruefen (docs/sicherheitsprotokoll.md).
// Absichtlich KEINE vollstaendige Content-Security-Policy -- sie braucht eine
// eigene Pruefung der Inline-Skripte von Next.js und bleibt im Sicherheitsprotokoll "offen".
// `frame-ancestors 'self'` ist nur die eine Richtlinie gegen das Einrahmen durch fremde Seiten
// (Gegenstueck zu X-Frame-Options fuer neuere Browser). Eingebettete FREMDE Seiten (XFrame-Kachel,