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
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:
+11
@@ -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
|
||||
+73
@@ -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"
|
||||
+104
@@ -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())
|
||||
+42
@@ -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)"
|
||||
+9
@@ -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
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
lauf-ohne-anmeldung POST=0 GET=8
|
||||
lauf-mit-anmeldung POST=0 auth_ja=8 auth_nein=1
|
||||
+5
@@ -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)
|
||||
+8
@@ -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
|
||||
+18
@@ -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
|
||||
+59
@@ -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()
|
||||
Reference in New Issue
Block a user