diff --git a/.gitea/scripts/image-start-check.sh b/.gitea/scripts/image-start-check.sh new file mode 100755 index 0000000..3d005bb --- /dev/null +++ b/.gitea/scripts/image-start-check.sh @@ -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" diff --git a/.gitea/scripts/security-scan.sh b/.gitea/scripts/security-scan.sh index 71577bb..d1e4e40 100755 --- a/.gitea/scripts/security-scan.sh +++ b/.gitea/scripts/security-scan.sh @@ -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 - setstatus gitleaks fehler + # 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 \ - --config p/nodejs --config p/secrets --config p/dockerfile \ - --metrics=off --quiet --timeout 20 --json --output "$REPORT/semgrep.json" . >/dev/null 2>&1 - ) + 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 } @@ -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,87 +578,80 @@ def sev_counts(results, key="Vulnerabilities"): return c -d = load("gitleaks.json") -if isinstance(d, list): - out.append("SECURITY-SUMMARY gitleaks findings=%d" % len(d)) -else: - skip("gitleaks") +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 +} -d = load("pnpm-audit-prod.json") -m = d.get("metadata", {}).get("vulnerabilities") if isinstance(d, dict) else None -if isinstance(m, dict): - out.append( - "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") - -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"))) - out.append("SECURITY-SUMMARY osv-scanner packages=%d" % len(pk)) -else: - skip("osv-scanner") - -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 [])) - ) -else: - skip("semgrep") - -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( - "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 - 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"]) - ) - else: - skip(tool) -else: - out.append("SECURITY-SUMMARY trivy-image skipped reason=kein-abbild-fuer-diesen-ref") - -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 } diff --git a/.gitea/scripts/zap-baseline.sh b/.gitea/scripts/zap-baseline.sh index eac9841..0208c4d 100755 --- a/.gitea/scripts/zap-baseline.sh +++ b/.gitea/scripts/zap-baseline.sh @@ -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 diff --git a/.gitea/scripts/zap-hooks.py b/.gitea/scripts/zap-hooks.py index e8c7b8f..f9ae4c6 100644 --- a/.gitea/scripts/zap-hooks.py +++ b/.gitea/scripts/zap-hooks.py @@ -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, ) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index d6e9937..eaf826d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 && \ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 7c1927d..7d12e60 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index b472afe..5c2a370 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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,