docs(quick-261009-p0m): Sicherheitsprotokoll und CI-Pruefungen
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Failing after 2m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Has been skipped
Tessera CI/CD / Build & Publish Images (push) Has been skipped
Tessera CI/CD / Sicherheitspruefung (nur Bericht) (push) Has been skipped

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:07:46 +02:00
parent 8a1218af6a
commit d62e6c2dbe
17 changed files with 1593 additions and 2 deletions
@@ -0,0 +1,11 @@
# Abschlusslauf 2026-10-09 auf HEAD fceec19 (frischer Klon, Runner-Abbild gitea/runner-images:ubuntu-latest, Netz gitea, GITHUB_REF=refs/heads/main, lokale Abbilder api:p0m-final/web:p0m-final = tessera-ctl-api/web:latest)
SECURITY-SUMMARY gitleaks findings=0
SECURITY-SUMMARY pnpm-audit-prod critical=0 high=9 moderate=3 low=0
SECURITY-SUMMARY osv-scanner packages=20
SECURITY-SUMMARY semgrep findings=33 errors=4
SECURITY-SUMMARY trivy-fs critical=0 high=9 medium=4 low=0 misconfig=2 secrets=0
SECURITY-SUMMARY trivy-image-api critical=0 high=6 medium=4 low=0
SECURITY-SUMMARY trivy-image-web critical=0 high=3 medium=1 low=0
SECURITY-SUMMARY fertig (nur Bericht, Exit 0)
rc=0
semgrep-regel gcm-no-tag-length=0
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# quick-261009-p0m, Task 5: Beweis, dass das Server-Abbild ohne Entwicklungswerkzeuge
# gegen eine FRISCHE Datenbank alle Migrationen anwendet und die API startet.
# Nur Testwerte; alles Angelegte wird per Falle entfernt. Druckt bei Erfolg
# "frische-datenbank ok".
set -u
IMAGE="${1:-tessera-ctl-api:latest}"
ID="p0m-fresh-$$"
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"
@@ -0,0 +1,104 @@
"""Linkpruefung fuer das Sicherheitsprotokoll und seine Verweise (quick-261009-p0m).
Aufruf vom Hauptordner des Repositorys: python3 -I <Pfad>/link-check.py
Geprueft werden in docs/sicherheitsprotokoll.md, docs/README.md,
docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md und docs/ci-cd-setup.md
alle relativen Markdown-Links, die
- im Sicherheitsprotokoll stehen, oder
- auf sicherheitsprotokoll.md zeigen, oder
- der Anker #sicherheitsprüfungen sind.
Das Linkziel (Datei) muss existieren, und ein Anker muss zu einer Ueberschrift des
Ziels passen (GitHub-Regel: Kleinbuchstaben, alles ausser Wortzeichen, Bindestrich
und Leerzeichen entfernen, Leerzeichen zu Bindestrichen). Exit 1 bei Fehlern.
"""
import os
import re
import sys
from urllib.parse import unquote
DOCS = [
"docs/sicherheitsprotokoll.md",
"docs/README.md",
"docs/anleitung-betrieb.md",
"docs/anleitung-entwicklung.md",
"docs/ci-cd-setup.md",
]
PROTOCOL = "docs/sicherheitsprotokoll.md"
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
def slug(heading):
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading) # Link -> Text
text = text.replace("`", "").replace("*", "")
text = text.strip().lower()
text = re.sub(r"[^\w\- ]", "", text)
return text.replace(" ", "-")
def anchors(path):
found = set()
in_fence = False
with open(path, encoding="utf-8") as handle:
for line in handle:
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
match = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line)
if match:
found.add(slug(match.group(2)))
return found
def main():
broken = []
checked = 0
cache = {}
for doc in DOCS:
if not os.path.exists(doc):
broken.append(f"{doc}: Datei fehlt")
continue
with open(doc, encoding="utf-8") as handle:
text = handle.read()
in_fence = False
for number, line in enumerate(text.splitlines(), 1):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
for target in LINK.findall(line):
if re.match(r"^[a-z][a-z0-9+.-]*:", target):
continue # http:, mailto: usw.
file_part, _, anchor = target.partition("#")
anchor = unquote(anchor)
relevant = (
doc == PROTOCOL
or file_part.endswith("sicherheitsprotokoll.md")
or anchor == "sicherheitsprüfungen"
)
if not relevant:
continue
checked += 1
dest = doc if file_part == "" else os.path.normpath(
os.path.join(os.path.dirname(doc), file_part)
)
if not os.path.exists(dest):
broken.append(f"{doc}:{number}: Ziel fehlt: {target}")
continue
if anchor:
if dest not in cache:
cache[dest] = anchors(dest)
if anchor not in cache[dest]:
broken.append(f"{doc}:{number}: Anker fehlt in {dest}: #{anchor}")
for line in broken:
print(line)
print(f"{checked} Links geprueft, {len(broken)} kaputt")
return 1 if broken else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Mail-Rauchtest (quick-261009-p0m, Aufgabe 4): beweist, dass die API nach dem Austausch von
# nodemailer und dem Entfernen des Mailer-Pakets weiterhin Mails an den lokalen Mailhog zustellt.
# Nur Testwerte (lokaler Stack, admin/admin123), liest keine .env-Dateien.
set -euo pipefail
LIB=".planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh"
cd "$(git rev-parse --show-toplevel)"
# shellcheck source=/dev/null
source "$LIB"
MAILHOG=${MAILHOG:-http://localhost:8025}
JAR="$E2E_TMP/mail-admin.jar"
mail_total() {
curl -sf "$MAILHOG/api/v2/messages?limit=1" \
| python3 -I -c 'import json,sys; print(json.load(sys.stdin)["total"])'
}
e2e_login "$JAR"
# /auth/me liefert keine E-Mail-Adresse; die Adresse des angemeldeten Admins steht in /users.
code=$(e2e_status "$JAR" GET "$API/users" "" "$E2E_TMP/users.out")
e2e_expect 200 "$code" "users"
email=$(python3 -I -c 'import json,sys
d=json.load(open(sys.argv[1]))
d=d if isinstance(d,list) else d.get("items",[])
print(next((u.get("email") or "" for u in d if u.get("username")=="admin"),""))' "$E2E_TMP/users.out")
[ -n "$email" ] || e2e_fail "Admin hat keine E-Mail-Adresse"
before=$(mail_total)
code=$(e2e_status "$JAR" POST "$API/auth/request-reset" "{\"email\":\"$email\"}")
e2e_expect 200 "$code" "request-reset"
for _ in $(seq 1 20); do
now=$(mail_total)
if [ "$now" -gt "$before" ]; then
echo "mail ok"
exit 0
fi
sleep 1
done
e2e_fail "keine neue Mail in Mailhog nach 20 Sekunden (vorher $before)"
@@ -0,0 +1,9 @@
SECURITY-SUMMARY gitleaks findings=1
SECURITY-SUMMARY pnpm-audit-prod critical=5 high=73 moderate=68 low=5
SECURITY-SUMMARY osv-scanner packages=56
SECURITY-SUMMARY semgrep findings=35 errors=4
SECURITY-SUMMARY trivy-fs critical=5 high=73 medium=70 low=5 misconfig=2 secrets=1
SECURITY-SUMMARY trivy-image-api critical=6 high=116 medium=99 low=7
SECURITY-SUMMARY trivy-image-web critical=2 high=19 medium=22 low=1
SECURITY-SUMMARY fertig (nur Bericht, Exit 0)
rc=0
@@ -0,0 +1,2 @@
lauf-ohne-anmeldung POST=0 GET=8
lauf-mit-anmeldung POST=0 auth_ja=8 auth_nein=1
@@ -0,0 +1,5 @@
vorher prod critical=5 high=73 moderate=68 low=5
vorher alle critical=7 high=85 moderate=74 low=5
nachher prod critical=0 high=9 moderate=3 low=0
nachher alle critical=2 high=9 moderate=5 low=0
neue Paketnamen im Lockfile: 0 (232 Namen entfallen, 0 hinzu)
@@ -0,0 +1,8 @@
# Task 5 Teil B: Laufzeitabbilder (Zahlen und Statuswoerter, keine Zugangsdaten)
# Zaehlung: trivy image --scanners vuln (Betriebssystem- und Node-Pakete), Host-Abbild tessera-ctl-{api,web}:latest
paketverwaltungen-im-node-abbild: /usr/local/lib/node_modules/{npm,corepack}, /opt/yarn-v1.22.22, /usr/local/bin/{npm,npx,corepack,yarn,yarnpkg}
vorher api groesse=1.59GB critical=3 high=45 medium=41 low=2
vorher web groesse=359MB critical=0 high=11 medium=13 low=1
nachher api groesse=1.12GB critical=0 high=6 medium=4 low=0
nachher web groesse=359MB critical=0 high=3 medium=1 low=0
ergebnis umgesetzt
@@ -0,0 +1,18 @@
datum 2026-10-09 19:41 (Ortszeit Entwicklungsrechner)
ziel direkt gegen die Anwendung auf alpha (ohne vorgeschalteten Proxy); oeffentliche Adresse vom Pruefrechner nicht erreichbar (Zeitueberschreitung)
version v1.10.1-80-gd15a470 channel=beta commit=d15a470 (abgefragt ueber /api-proxy/health/version)
abbild ZAP 2.17.0, klassische Spinne 3 Minuten, 28 URLs, keine Formulare, kein POST
ZAP-Grundpruefung gegen http://192.168.13.12:3000 (nur passiv, keine Formulare, keine Angriffe)
ZAP-Lauf beendet (Protokoll: /home/vicolab/projects/tessera-ctl/security-reports/zap-alpha-intern-20261009/zap-lauf.log)
ZAP-SUMMARY ziel=192.168.13.12:3000 hoch=0 mittel=2 niedrig=6 info=3
ZAP-MELDUNG mittel Content Security Policy (CSP) Header Not Set
ZAP-MELDUNG mittel Missing Anti-clickjacking Header
ZAP-MELDUNG niedrig Cross-Origin-Embedder-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Cross-Origin-Opener-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Cross-Origin-Resource-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Permissions Policy Header Not Set
ZAP-MELDUNG niedrig Server Leaks Information via "X-Powered-By" HTTP Response Header Field(s)
ZAP-MELDUNG niedrig X-Content-Type-Options Header Missing
ZAP-MELDUNG info Content-Type Header Missing
ZAP-MELDUNG info Non-Storable Content
ZAP-MELDUNG info Storable and Cacheable Content
@@ -0,0 +1,59 @@
"""Kleiner Testserver fuer den lokalen Passivitaetsbeweis der ZAP-Grundpruefung.
Aufruf: python3 -I zap-testserver.py PORT LOGDATEI [require-auth]
/ verweist auf /login und /info, /login enthaelt ein POST-Formular mit Benutzer,
Passwort und Absende-Knopf. Jede Anfrage wird als "METHODE PFAD auth=ja|nein"
protokolliert. Mit require-auth beantwortet der Server jede Anfrage ohne
Authorization-Kopf mit 401. Nur Testwerte, keine echten Zugangsdaten.
"""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(sys.argv[1])
LOG = sys.argv[2]
REQUIRE_AUTH = len(sys.argv) > 3 and sys.argv[3] == "require-auth"
PAGES = {
"/": '<html><body><a href="/login">Anmelden</a> <a href="/info">Info</a></body></html>',
"/login": (
'<html><body><form method="POST" action="/login">'
'<input type="text" name="user"><input type="password" name="password">'
'<input type="submit" value="Anmelden"></form></body></html>'
),
"/info": '<html><body><p>Info</p><a href="/">zurueck</a></body></html>',
}
class Handler(BaseHTTPRequestHandler):
def _log(self):
auth = "ja" if self.headers.get("Authorization") else "nein"
with open(LOG, "a", encoding="utf-8") as fh:
fh.write("%s %s auth=%s\n" % (self.command, self.path, auth))
return auth == "ja"
def _answer(self):
has_auth = self._log()
if REQUIRE_AUTH and not has_auth:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="test"')
self.send_header("Content-Length", "0")
self.end_headers()
return
body = PAGES.get(self.path.split("?")[0], "<html><body>nicht gefunden</body></html>")
code = 200 if self.path.split("?")[0] in PAGES else 404
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = _answer
def log_message(self, *args):
pass
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()