#!/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