diff --git a/.dockerignore b/.dockerignore index 1e70058..e343029 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,4 @@ dist # (apps/web/next.config.ts liest sie zur Bauzeit, COPY im Web-Dockerfile). !CHANGELOG.md coverage +security-reports diff --git a/.gitea/scripts/security-scan.sh b/.gitea/scripts/security-scan.sh new file mode 100755 index 0000000..71577bb --- /dev/null +++ b/.gitea/scripts/security-scan.sh @@ -0,0 +1,610 @@ +#!/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) +# 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). +# +# 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_VERSION=1.180.0 +PNPM_VERSION=9.15.0 + +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" +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" + +# --- 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" + 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="" +cleanup() { + [ -n "$WORK" ] && rm -rf "$WORK" + return 0 +} +trap cleanup EXIT +trap 'exit 0' INT TERM + +# Laufzeitgrenze je Werkzeug, falls `timeout` vorhanden ist. +tmo() { + _secs="$1" + shift + 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 +} + +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 600 -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 [ -x "$SEMGREP_BIN" ] && "$SEMGREP_BIN" --version 2>/dev/null | grep -q "^$SEMGREP_VERSION"; then + echo "Werkzeug semgrep $SEMGREP_VERSION bereit (vorhanden)" + return 0 + fi + if ! command -v pipx >/dev/null 2>&1; then + skipline semgrep kein-pipx + 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" + else + skipline semgrep install + fi +} + +# pnpm: eine pnpm 9.15.x im PATH, sonst corepack mit angepinnter Version. +PNPM_MODE="" +detect_pnpm() { + 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 120 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 300 pnpm "$@" + else + COREPACK_HOME="$COREPACK_HOME_DIR" COREPACK_ENABLE_DOWNLOAD_PROMPT=0 tmo 300 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 -------------------------------------------------------------- +scan_gitleaks() { + if [ ! -x "$GITLEAKS_BIN" ]; then + setstatus gitleaks nicht-installiert + return 0 + fi + setstatus gitleaks fehler + ( + cd "$ROOT" && + tmo 900 "$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 + ) + return 0 +} + +scan_pnpm_audit() { + if ! detect_pnpm; then + setstatus pnpm-audit-prod kein-pnpm + return 0 + fi + setstatus pnpm-audit-prod fehler + ( + cd "$WORK/src" && run_pnpm audit --prod --json >"$REPORT/pnpm-audit-prod.json" 2>/dev/null + ) + return 0 +} + +scan_osv() { + if [ ! -x "$OSV_BIN" ]; then + setstatus osv-scanner nicht-installiert + return 0 + fi + setstatus osv-scanner fehler + ( + cd "$WORK/src" && + tmo 600 "$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 + ) + return 0 +} + +scan_semgrep() { + if [ ! -x "$SEMGREP_BIN" ]; then + setstatus semgrep nicht-installiert + 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 + ) + 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 + return 0 + fi + setstatus trivy-fs fehler + write_trivy_secret_config + 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 \ + --scanners vuln,misconfig,secret --secret-config "$WORK/trivy-secret.yaml" \ + --exit-code 0 --format json --output "$REPORT/trivy-fs.json" . >/dev/null 2>&1 + ) + return 0 +} + +scan_trivy_images() { + [ -n "$IMAGES" ] || return 0 + for _img in $IMAGES; do + _name="$(image_name "$_img")" + if [ ! -x "$TRIVY_BIN" ]; then + setstatus "trivy-image-$_name" nicht-installiert + 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 + continue + fi + setstatus "trivy-image-$_name" fehler + mkdir -p "$TRIVY_CACHE" 2>/dev/null + tmo 900 "$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 + 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 ' +import json, os, sys + +rep, st, names = sys.argv[1], sys.argv[2], sys.argv[3].split() +out = [] + + +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 skip(tool): + try: + with open(os.path.join(st, tool), encoding="utf-8") as f: + reason = f.read().split()[0] + except Exception: + reason = "kein-bericht" + out.append("SECURITY-SUMMARY %s skipped reason=%s" % (tool, reason)) + + +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 + + +d = load("gitleaks.json") +if isinstance(d, list): + out.append("SECURITY-SUMMARY gitleaks findings=%d" % len(d)) +else: + skip("gitleaks") + +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 + else + skipline summary kein-python + echo "SECURITY-SUMMARY fertig (nur Bericht, Exit 0)" + fi + return 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" + + # 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; done + fi + scan_trivy_images + + # Layer-Zwischenspeicher von Trivy waechst stark; die Datenbank bleibt. + rm -rf "$TRIVY_CACHE/fanal" 2>/dev/null + + summarize + 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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 34eafbc..8d5778d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,6 +17,9 @@ # quick-260917-kgc: Die beiden tauri-build-Schritte signieren die Pakete mit dem # Updater-Schluessel (Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD, nur an diesen # zwei Schritten); desktop-collect.sh traegt die .sig-Inhalte ins Manifest. +# quick-261009-p0m: Job `security` (Sicherheitspruefung) laeuft nach `publish`, nur auf +# main und Tags v*, ist reiner Bericht (continue-on-error, nie in einem `needs`, kein +# Secret) und ruft .gitea/scripts/security-scan.sh auf. name: Tessera CI/CD on: @@ -258,3 +261,35 @@ jobs: env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: sh .gitea/scripts/publish-release.sh + + security: + name: Sicherheitspruefung (nur Bericht) + runs-on: ubuntu-latest + needs: publish + # Ausdruecklich noetig: Ein uebersprungener `needs`-Job blockiert in Gitea nicht, + # ohne diese Bedingung liefe der Job auch nach einem Push auf `live` (ohne Abbilder). + if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v') + # Reiner Bericht: Ergebnis darf nie etwas blockieren oder den Lauf rot faerben. + continue-on-error: true + # Ein haengender Scan darf den (einzigen) Runner nicht dauerhaft belegen. + timeout-minutes: 30 + steps: + # Volle Historie: gitleaks liest alle Commits. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Kein Secret in diesem Job; das Skript bricht nie ab (siehe docs/ci-cd-setup.md). + - name: Sicherheitspruefungen (nur Bericht) + run: sh .gitea/scripts/security-scan.sh all || true + + # actions/upload-artifact@v4 lehnt Gitea ab; v3 ist der laufende Stand. + # Best effort: Die Zeilen SECURITY-SUMMARY im Protokoll genuegen immer. + - name: Berichte als Artefakt ablegen + if: always() + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: sicherheitsberichte + path: security-reports/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index a12373f..4f0aff6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ desktop-dist/* # Privater Updater-Signierschluessel liegt ausserhalb des Repos (~/.tessera/desktop-updater/) -- nie einchecken *.key + +# Berichte der Sicherheitspruefung (quick-261009-p0m, .gitea/scripts/security-scan.sh) +security-reports/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..14823a5 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,47 @@ +# gitleaks-Einstellungen fuer Tessera (quick-261009-p0m). +# Die Standardregeln bleiben vollstaendig aktiv; unten stehen nur Ausnahmen fuer +# Treffer, die einzeln geprueft wurden und keine echten Zugangsdaten sind. +# Grundsatz: so eng wie moeglich -- ein Ordner nur dort, wo er allein Testschluessel +# enthaelt, alles andere als einzelne Datei mit Regel (und bei produktivem Code +# zusaetzlich mit der geprueften Zeile). Ein neuer Treffer wird nie vorsorglich +# freigegeben, sondern erst geprueft. + +title = "Tessera gitleaks" + +[extend] +useDefault = true + +[[allowlists]] +description = "Testschluessel des Zertifikatsmanagers: Der Ordner existiert nur, um Schluesseldateien fuer Tests bereitzuhalten (zufaellig erzeugt, nirgends verwendet)." +paths = ['''^apps/api/src/cert-manager/__fixtures__/'''] + +[[allowlists]] +description = "Schluessel-Ausschnitte (-----BEGIN ...) in den Tests des Zertifikatsmanagers und in einer Recherche-Notiz: Testdaten bzw. Beschreibungstext, keine echten Schluessel." +targetRules = ["private-key"] +paths = [ + '''^apps/api/src/cert-manager/cert-keys\.spec\.ts$''', + '''^apps/api/src/cert-manager/cert-output\.spec\.ts$''', + '''^apps/api/src/cert-manager/cert-templates\.spec\.ts$''', + '''^\.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-RESEARCH\.md$''', +] + +[[allowlists]] +description = "Testwert 'Benutzer/Passwort' im Formular-Test des Proxmox-Moduls und Tabellenzeile im Pruefplan der Ausschreibungs-Benachrichtigungen: Beschriftungen, keine Zugangsdaten." +targetRules = ["generic-api-key"] +paths = [ + '''^apps/web/src/app/\(portal\)/modules/proxmox/settings/components/ServerForm\.test\.tsx$''', + '''^\.planning/phases/12-tender-notifications/12-VALIDATION\.md$''', +] + +[[allowlists]] +description = "Beschriftung der Anmeldemethode in der deutschen Oberflaechen-Textdatei (Zeile 'authMethodPassword': 'Benutzer/Passwort'): nur diese eine gepruefte Zeile." +condition = "AND" +targetRules = ["generic-api-key"] +paths = ['''^apps/web/src/messages/de\.json$'''] +regexTarget = "line" +regexes = ['''"authMethodPassword"\s*:\s*"Benutzer/Passwort"'''] + +[[allowlists]] +description = "curl-Beispiel gegen den lokalen Wegwerf-Nextcloud-Testcontainer in einer Recherche-Notiz (Platzhalter statt Zugangsdaten)." +targetRules = ["curl-auth-user"] +paths = ['''^\.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-RESEARCH\.md$'''] diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..040d4cf --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,15 @@ +# Semgrep-Ausnahmen (quick-261009-p0m). Test- und Hilfsdateien sind keine Auslieferung; +# so bleibt der Bericht auf echten Quelltext konzentriert. +:include .gitignore + +# Testschluessel und Testdaten +apps/api/src/cert-manager/__fixtures__/ +apps/api/src/tenders/__fixtures__/ + +# Tests +*.spec.ts +*.test.ts +*.test.tsx + +# Planungsnotizen und Pruefskripte +.planning/ diff --git a/docs/ci-cd-setup.md b/docs/ci-cd-setup.md index e090679..3cfcbf8 100644 --- a/docs/ci-cd-setup.md +++ b/docs/ci-cd-setup.md @@ -119,7 +119,7 @@ hardcoden. Die CI/CD-Pipeline (`.gitea/workflows/ci.yml`) laeuft bei jedem Push auf die Zweige `main` und `live` sowie bei jedem Tag `v*` (z. B. `v1.0.0`) und besteht -aus vier aufeinander aufbauenden Jobs: +aus fuenf Jobs (vier bauen aufeinander auf, der fuenfte ist ein reiner Bericht): 1. **quality** -- Lint und TypeScript Type-Check (Lint ist derzeit ein Leerlauf, siehe WINDOWS #35; der Type-Check ist echt) @@ -128,9 +128,12 @@ aus vier aufeinander aufbauenden Jobs: und bei Tags `v*`, siehe unten) 4. **publish** -- Docker Images mit Versionsstempel bauen und in die Gitea-Registry veroeffentlichen +5. **security** -- Sicherheitspruefung (nur Bericht): nach `publish`, nur auf `main` + und bei Tags `v*`; bricht nie ab, blockiert nie etwas, bekommt kein Secret -Ablauf: `quality` -> `test` -> `desktop` -> `publish` (jeder Job nur bei Erfolg -des vorherigen; `desktop` selbst laeuft nur, wenn die `if`-Bedingung zutrifft +Ablauf: `quality` -> `test` -> `desktop` -> `publish` -> `security` (jeder Job nur +bei Erfolg des vorherigen; `security` ist nur ein Bericht und steht in keinem +`needs` eines anderen Jobs; `desktop` selbst laeuft nur, wenn die `if`-Bedingung zutrifft -- auf einem Push nach `live` ohne Tag entfaellt der Job, `publish` startet in diesem Fall trotzdem, weil `needs: desktop` bei einem uebersprungenen Job nicht blockiert). Der Job `publish` besteht aus sechs Schritten: @@ -264,6 +267,58 @@ Hinweis: Die fruehere Entscheidung D-13 (Images lokal bauen, keine Registry) ist ueberholt -- seit der Einfuehrung der Gitea-Registry werden Images gepusht und von den Servern per `docker compose pull` geholt. +### Job `security`: Sicherheitspruefung (nur Bericht) + +Der Job `security` (quick-261009-p0m) laeuft nach `publish`, damit er die Veroeffentlichung +nie verzoegert oder verhindert. Er laeuft nur auf `main` und bei Tags `v*`; die +Bedingung steht ausdruecklich am Job, weil ein uebersprungenes `needs` in Gitea nicht +blockiert (sonst liefe er auch nach einem Push auf `live`). Er hat `continue-on-error`, +ein Zeitlimit von 30 Minuten (ein haengender Scan haelt den einzigen Runner nicht fest) +und kein Secret. Alles steckt in einem Skript, das auch lokal laeuft: + +```bash +GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh --print-plan # ohne Netz +GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh all +``` + +**Was geprueft wird:** gitleaks ueber die gesamte Git-Historie (Zugangsdaten), `pnpm audit +--prod` und osv-scanner ueber `pnpm-lock.yaml` und die Desktop-`Cargo.lock` (bekannte +Schwachstellen), Semgrep (statische Code-Analyse) und Trivy ueber den Quellstand sowie +ueber die frisch gebauten Abbilder `api` und `web` (`main` -> `:beta`, Tag `v*` -> `:live`). +Die Abbilder liegen nach `publish` im Docker-Daemon des Hosts (Socket-Mount, Abschnitt 5) +und werden direkt von dort gelesen. + +**Quellstand:** Die Quellpruefungen lesen einen `git archive HEAD`-Export in einem +temporaeren Ordner, gitleaks nur die Git-Historie. Es wird also genau der eingecheckte +Stand geprueft; nicht eingecheckte Dateien eines Arbeitsordners erreichen nie ein +Werkzeug, einen Bericht oder ein Artefakt. + +**Angepinnte Werkzeuge:** gitleaks 8.30.1, Trivy 0.75.0, osv-scanner 2.6.0, Semgrep 1.180.0 +(`pipx`), pnpm 9.15.0. Die drei Programme werden von der offiziellen +GitHub-Veroeffentlichung geladen und vor jeder Benutzung gegen eine SHA256-Summe im Skript +geprueft (auch das zwischengespeicherte Archiv); stimmt die Summe nicht oder fehlt das +Netz, wird nur dieses Werkzeug uebersprungen. Marktplatz-Aktionen fuer Scanner werden +bewusst nicht benutzt (veraenderliche Etiketten). **Version anheben:** im Kopf von +`security-scan.sh` die Version, die URL und die SHA256-Summe aus der offiziellen +Pruefsummendatei der Veroeffentlichung gemeinsam austauschen und einmal lokal +`sh .gitea/scripts/security-scan.sh all` laufen lassen. Werkzeuge und die Trivy-Datenbank +liegen im persistenten Zwischenspeicher des Runners (`/opt/hostedtoolcache/tessera-security`); +der Layer-Zwischenspeicher von Trivy wird nach jedem Lauf geloescht, damit die Platte nicht +vollaeuft. + +**Ausnahmelisten:** `.gitleaks.toml` (geprueft harmlose Treffer: Testschluessel des +Zertifikatsmanagers, Schluessel-Ausschnitte in Tests und Notizen) und `.semgrepignore` +(Tests, Testdaten, Planungsnotizen). Ein neuer Treffer wird nie vorsorglich freigegeben, +sondern erst geprueft. + +**Wo man das Ergebnis sieht:** Im Protokoll des Jobs stehen je Werkzeug eine Zeile +`SECURITY-SUMMARY ...` (nur Zahlen) und am Ende `SECURITY-SUMMARY fertig (nur Bericht, +Exit 0)`; dieselben Zeilen stehen in `summary.txt`. Die Rohberichte (JSON) haengen, +soweit der Runner es zulaesst, als Artefakt `sicherheitsberichte` (30 Tage) am Lauf. Das +Artefakt ist nur "best effort" (Gitea lehnt `upload-artifact@v4` ab, es laeuft `@v3`); +verlassen Sie sich auf die Zeilen im Protokoll. Der Job braucht ungefaehr fuenf bis acht +Minuten nach `publish`. + ## 5. Sicherheitshinweise ### Docker Socket @@ -420,3 +475,16 @@ ausdruecklich auf die oeffentliche Adresse gesetzt wird. Fehlende Anhaenge lassen sich jederzeit vom Host nachtragen: `GITEA_TOKEN=... DESKTOP_DIST= sh .gitea/scripts/publish-release.sh --tag vX.Y.Z` (die Pakete liegen im API-Abbild unter `/app/desktop-dist`). + +### Job `security` ist rot oder gelb + +Der Job ist ein reiner Bericht und beeinflusst weder Abbilder noch Freigaben (er laeuft +erst nach `publish` und steht in keinem `needs`). Rot oder gelb heisst deshalb nie, dass +etwas nicht ausgeliefert wurde. Zur Ursache im Job-Protokoll die Zeilen +`SECURITY-SUMMARY skipped reason=...` lesen: `download` oder `checksum` (Werkzeug +nicht ladbar bzw. Summe stimmt nicht -- Netz pruefen, nie die Summe ohne Pruefung +austauschen), `kein-pipx` / `install` (Semgrep), `kein-pnpm`, `abbild-fehlt` (das Abbild +liegt nicht im Docker-Daemon des Hosts), `fehler` (Werkzeug ist abgestuerzt oder hat +nichts geschrieben, z. B. Zeitlimit), `kein-bericht`. Ein haengender Lauf endet nach 30 +Minuten von selbst. Fehlt nur das Artefakt, ist das erwartbar (best effort); die Zahlen +stehen trotzdem im Protokoll.