# 🛡️ Security-Hardening – die ganze Geschichte

Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
Code-Review und öffentlichem Deployment durchlaufen hat.

Format pro Runde: **Was war kaputt** → **Wie es gefixt wurde** → wo möglich
**Live-Test-Resultate**.

> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).

---

## 📊 Live-verifizierte Tests im Überblick

Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
durchgespielt – statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
brauchten Live-Tests.

### Runde 4 – IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)

| Endpoint                                     | Vorher                          | Nachher                      |
| -------------------------------------------- | ------------------------------- | ---------------------------- |
| `GET /api/customers/4`                       | 🚨 **200 mit Daten**            | ✅ 403                       |
| `GET /api/customers/4/addresses`             | 🚨 200                          | ✅ 403                       |
| `GET /api/customers/4/bank-cards`            | 🚨 200                          | ✅ 403                       |
| `GET /api/customers/4/documents`             | 🚨 200                          | ✅ 403                       |
| `GET /api/customers/4/meters`                | 🚨 200                          | ✅ 403                       |
| `GET /api/customers/4/representatives`       | 🚨 200                          | ✅ 403                       |
| `GET /api/gdpr/customer/4/consents`          | 🚨 200 mit Consent-Daten        | ✅ 403                       |
| `GET /api/gdpr/customer/4/authorizations`    | 🚨 200                          | ✅ 403                       |
| `GET /api/gdpr/customer/4/consent-status`    | 🚨 200                          | ✅ 403                       |
| Eigene Daten `/api/customers/1`              | ✅ 200                          | ✅ 200 (unverändert)         |
| 12 MB Body                                   | 500 „Interner Serverfehler"     | ✅ 413 „Anfrage zu groß"     |
| Malformed JSON                               | 500 „Interner Serverfehler"     | ✅ 400 „Ungültiges JSON"     |

### Runde 5 – DSGVO-GAU + Timing-Side-Channel

| Test                                              | Vorher                                  | Nachher                            |
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| `/api/uploads/cancellation-confirmations/*.pdf`   | 🚨 **HTTP 200 mit echtem Kunden-PDF**   | ✅ 401 ohne Token                  |
| `/api/uploads/...?token=<jwt>`                    | n/a                                     | ✅ 200                             |
| Login `admin@admin.com` (falsches Passwort)       | 110 ms                                  | 423 ms                             |
| Login `not-existent@x.de`                         | 10 ms (verräterisch)                    | 422 ms (matcht admin)              |
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt)            | ✅ 403                             |

### Runde 6 – Customer-Liste-Leak + XFF-Bypass

| Test                                          | Vorher                                  | Nachher                                  |
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
| `GET /api/customers` als Portal               | 🚨 **alle Kunden mit Namen/E-Mail**     | ✅ nur eigene + vertretene               |
| 12× Login mit rotierendem `X-Forwarded-For`   | 🚨 alle 401, kein 429                   | ✅ XFF nur von Loopback akzeptiert       |
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt                  | ✅ 400                                   |
| Authorization für non-existent Customer 9999  | 🚨 Prisma-Stack mit Pfaden geleakt      | ✅ 403 generisch                         |
| Customer-Existence via 404-vs-403             | 🟡 enumerierbar                         | ✅ alle 403 uniform                      |
| Listen-Adresse (Production)                   | `0.0.0.0` (extern erreichbar)           | `127.0.0.1` (nur via Reverse-Proxy)      |

### Runde 7 – SSRF + Logout

| Test                                                        | Vorher                | Nachher                                  |
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
| `test-connection` mit `apiUrl=http://169.254.169.254`       | 8 s Timeout (SSRF)    | ✅ 400 „geblockte Adresse"               |
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch    | ✅ 400                                   |
| `test-mail-access` mit `0.0.0.0`                            | Connection-Versuch    | ✅ 400                                   |
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall)             | OK                    | ✅ OK (weiter erlaubt)                   |
| `POST /api/auth/logout`                                     | 404 (Endpoint fehlte) | ✅ 200                                   |
| `GET /me` nach Logout                                       | weiter 200 (bis 7 d)  | ✅ 401 „Sitzung ungültig"                |

### Runde 8 – DNS-Rebinding + Per-File-Ownership

| Test                                                  | Resultat                                      |
| ----------------------------------------------------- | --------------------------------------------- |
| Admin lädt eigene Datei                               | ✅ HTTP 200, PDF                              |
| Portal lädt eigene Contract-Datei                     | ✅ HTTP 200, PDF                              |
| Portal lädt random Pfad ohne DB-Resource              | ✅ HTTP 404                                   |
| Path-Traversal `..` im Pfad                           | ✅ HTTP 400                                   |
| URL-encoded Traversal `%2F..%2F`                      | ✅ HTTP 400                                   |
| Ohne Token                                            | ✅ HTTP 401                                   |
| Backwards-Compat `/api/uploads/<path>`                | ✅ HTTP 200 (intern derselbe Owner-Check)     |
| Legitimer Hostname (gmail.com)                        | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail    |
| Hostname mit interner Target-IP                       | ✅ HTTP 400 geblockt                          |

### Runde 9 – Vorher überprüft, Dependency-Audit, Audit-Chain

| Test                                                       | Resultat                                                |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| `From`-Address-Header-Injection (CRLF in fromAddress)      | ✅ bereits in Stage 3 abgefangen (`containsCRLF`)        |
| `npm audit` (initial)                                      | 9 Vulns (4× high)                                       |
| `npm audit fix`                                            | ✅ 8 transitive Vulns gefixt                            |
| nodemailer breaking-update auf 8.x                         | 📋 als v1.1-Item dokumentiert                           |
| Audit-Log Hash-Chain vor `rehashAll`                       | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) |
| Audit-Log Hash-Chain nach `rehashAll`                      | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) |
| Authenticated Rate-Limit (50 parallele Requests)           | 🟡 keiner – DoS-Schutz vom Reverse-Proxy übernehmen      |
| Frontend `localStorage` Token-Stealing-Vektor              | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau  |

---

## 🗂️ Runde-für-Runde

### Runde 1 – Erste kritische Findings (statisches Review)

- CORS komplett offen → `CORS_ORIGINS` explizit
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
- XSS via Email-Body → DOMPurify mit strikter Config
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
- Body-Size-Limit 5 MB

### Runde 2 – Deep-Dive (parallele Audit-Agents)

- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist

### Runde 3 – Tiefer Dive (8 weitere Hardenings)

- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
- IDOR PDF-Template-Generator → `canAccessContract`
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
- bcrypt cost 10 → 12 (OWASP 2026)

### Runde 4 – Live-Tests gegen Dev-Server (Tabelle oben)

`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.

Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).

### Runde 5 – Hack-Das-Ding-Audit

- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
  Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
  (Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
  (`canAccessContract`).

### Runde 6 – Tiefer Live-Pentest (Tabelle oben)

- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For** → `trust proxy = 'loopback'`
  + `LISTEN_ADDR=127.0.0.1` in Production
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
  400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
  beide Fehlfälle uniform 403.
- Prisma-Error-Leaks generisch ersetzt.

### Runde 7 – Letzter Schliff

- **SSRF-Schutz** in `test-connection` und `test-mail-access` →
  `utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
  Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
  Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
  / `portalTokenInvalidatedAt` auf jetzt.

### Runde 8 – Loose Ends

- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
  zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
  zurück. Connection läuft gegen IP, der Hostname als TLS-SNI – ein
  zweiter DNS-Lookup kann keine geblockte IP unterschieben.
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
  express.static)` ersetzt durch `GET /api/files/download?path=...` mit
  DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
  oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
  Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.

### Runde 10 – Security-Monitoring + Alerting

Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
zumindest **gesehen** werden. Ergänzt:

- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
  + Indexen für schnelles Filter+Threshold-Detection.
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
  Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
  (`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
  (Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
- **Service `securityAlert.service.ts`** mit:
  - **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
    gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
    HIGH/5min → erzeugt synthetische CRITICAL-Events.
  - **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
    (debounced, max. 1× pro Stunde + IP).
  - **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
    in einer Mail (wenn `monitoringDigestEnabled = true`).
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
  Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
  Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
  Auto-Refresh alle 30s.
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
  – alle hinter `settings:read` / `settings:update`.

Live-verifiziert (1. Mai 2026):

| Test                                                | Resultat                                            |
| --------------------------------------------------- | --------------------------------------------------- |
| Login-Fehlversuch                                   | ✅ `LOW LOGIN_FAILED` Event erzeugt                  |
| Login-Erfolg                                        | ✅ `INFO LOGIN_SUCCESS` Event                        |
| Portal-User probiert 4× fremde Customer-IDs         | ✅ 4× `MEDIUM ACCESS_DENIED` Events                  |
| Admin SSRF-Probe (169.254.169.254)                  | ✅ `HIGH SSRF_BLOCKED` Event                         |
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min  | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
| CRITICAL-Sofort-Alert per E-Mail                    | ✅ binnen 30 s zugestellt                            |
| Test-Alert-Button                                   | ✅ E-Mail mit Test-Marker zugestellt                 |
| Hourly-Digest mit 5 Events                          | ✅ E-Mail mit Tabellen-Übersicht zugestellt          |

### Runde 9 – Diminishing-Returns-Runde

Nichts Kritisches mehr gefunden. Liefert noch:

- **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability
  (lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene
  nodemailer-Vuln braucht Major-Update (v6 → v8) – v1.1-Item.
- **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch
  frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll`
  repariert; integrity-check verifiziert die Chain wieder. Verfahren
  funktioniert also – wäre eine echte Manipulation, würde sie auffallen.
- **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die
  zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
  atomar gelöscht – zweiter Versuch findet keinen Token. ✅

### Runde 11 – Externer Pentest-Folge: Header-Hygiene + Klartext-Audit

Externer Pentest (testssl, ZAP, Nikto, Nuclei) gegen Prod-VM hat drei
Klassen Defense-in-Depth-Findings rausgespült. Reale Ausnutzbarkeit jeweils
gering, aber Audit-Bewertung fordert konsistente Header-Hygiene.

- **HSTS-Doppel-Header (18×)**: Nginx-Proxy-Manager (TLS-Terminierung) UND
  Helmet schickten beide `Strict-Transport-Security` → RFC-6797-Verletzung.
  Helmet's HSTS deaktiviert (`strictTransportSecurity: false`); der
  Reverse-Proxy übernimmt die Policy zentral am Edge.
- **Cache-Control (~10×)**: `/api/*` → `no-store` (sensible JSON-Daten),
  SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`) → `no-store, must-revalidate`
  (sonst hängt der Browser nach Deploy an alter `index.html` fest),
  `/assets/*.{js,css}` → `public, max-age=31536000, immutable` (Vite-Bundles
  haben Content-Hash im Filename).
- **CSP No-Fallback-Direktiven (2×)**: `worker-src`, `manifest-src`,
  `media-src` jetzt explizit auf `'self'`.
- **CSP `frame-ancestors`**: war `'none'`, das blockt auch same-origin-iframes
  → PDF-Vorschau im PDF-Template-Editor lädt nicht. Korrigiert auf
  `'self'` (eigene App darf eigene Resourcen embeden, externe Sites bleiben
  via `X-Frame-Options: SAMEORIGIN` weiter gesperrt).
- **BREACH (CVE-2013-3587)**: testssl meldet "potentially VULNERABLE,
  gzip HTTP compression detected" – theoretischer Side-Channel-Angriff
  auf gzip-komprimierte HTTPS-Responses. Praktisch klein bei JWT-SPA (keine
  reflektierten Secrets im Response), Audit-Marker bleibt aber MEDIUM.
  Fix: gzip im Reverse-Proxy für `/api/*` deaktivieren (Custom-Location im
  NPM, Statische Assets bleiben weiter komprimiert). README dokumentiert
  Setup.
- **`Server: openresty` + `x-served-by`-Banner**: am NPM via
  `more_clear_headers Server X-Served-By;` weg.
- **Audit-Log für Klartext-Passwort-Reads**: Pentest fand "HOCH (post-auth):
  Klartext-Passwörter über API abrufbar" — reversible AES-256-GCM ist
  by-design für das Feature "Anbieter-Login anzeigen", aber **keiner** der
  sechs Endpoints (`PortalPassword`, `ContractPassword`, `SimCardCredentials`,
  `InternetCredentials`, `SipCredentials`, `MailboxCredentials`) schrieb
  bisher einen Audit-Log-Eintrag. Jetzt: `action: 'READ'` mit eigenem
  Resource-Type + `sensitivity: CRITICAL`, Label nennt explizit "Klartext
  … entschlüsselt" + Resource-ID. Damit ist im Audit-Log-Viewer jederzeit
  nachvollziehbar, wer wann welches Passwort eingesehen hat
  (DSGVO + Insider-Threat).

### Runde 13 – KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Live-Pentest-Fund)

Externer Pentest hat einen echten Credential-Exfiltration-Angriff erfolgreich
durchgespielt: **als Portal-User von Kunde A komplette IMAP/SMTP-Klartext-
Credentials von Kunde B abgreifen können**.

**Angriffspfad:**
1. Portal-Login als Kunde A
2. `/api/stressfrei-emails/{id}` GET unterschied saubere Antworten:
   - „E-Mail-Konto nicht gefunden" (ID existiert nicht)
   - „Kein Zugriff auf diese Kundendaten" (ID existiert, gehört anderem)
   → Information-Disclosure: Existenz von IDs durchprobierbar
3. `/api/stressfrei-emails/{id}/credentials` GET ohne Ownership-Check →
   IMAP/SMTP-Server, Username und **Klartext-Passwort** der fremden Mailbox

**Root Cause:** der Haupt-Endpoint `GET /:id` hatte `canAccessStressfreiEmail`,
die 8 Sub-Endpoints unter `:id/*` hatten **alle keinen** Ownership-Check —
nur `authenticate + requirePermission('customers:read')`, was jeder Portal-User
hat.

**Betroffene Endpoints (alle gefixt):**
- `GET /:id/credentials` ← **der kritische** (Klartext-Passwort + IMAP/SMTP)
- `GET /:id/folder-counts`
- `POST /:id/sync`
- `POST /:id/send`
- `POST /:id/enable-mailbox`
- `POST /:id/sync-mailbox-status`
- `POST /:id/reset-password`
- `PUT /:id` (updateEmail im stressfreiEmail.controller)
- `DELETE /:id` (deleteEmail)

`canAccessStressfreiEmail(req, res, emailId)` als erste Zeile in jedem
Controller. `canAccessResourceByCustomerId` emittiert bei Fehlversuch
automatisch ein `ACCESS_DENIED MEDIUM`-Event ins Security-Monitoring → bei
>5 Versuchen in 5 min wird ein `CRITICAL SUSPICIOUS`-Event erzeugt + Alert
verschickt.

**Live-verifiziert (Portal-User Kunde A versucht Email-ID von Kunde B):**

| Endpoint | Vorher | Nachher |
| --- | --- | --- |
| `GET /:id/credentials` | 🚨 **200 mit Klartext-Passwort** | ✅ 403 |
| `GET /:id/folder-counts` | 🚨 200 | ✅ 403 |
| `POST /:id/sync` | 🚨 200 | ✅ 403 |
| `POST /:id/send` | 🚨 fremde Mailbox zum Versand missbrauchbar | ✅ 403 |
| `POST /:id/enable-mailbox` | 🚨 200 | ✅ 403 |
| `POST /:id/sync-mailbox-status` | 🚨 200 | ✅ 403 |
| `POST /:id/reset-password` | 🚨 fremdes Mailbox-Passwort zurücksetzbar | ✅ 403 |
| `POST /:id/sync-forwarding` | (vorher schon gefixt) | ✅ 403 |
| `PUT /:id` | 🚨 fremde Adresse änderbar | ✅ 403 |
| `DELETE /:id` | 🚨 fremde Adresse löschbar | ✅ 403 |
| Eigene Email-ID | (legitim) | ✅ 200/400 (durch) |
| Security-Monitor | – | 8× `ACCESS_DENIED MEDIUM` geloggt ✅ |

**Lehre:** wenn ein Haupt-Endpoint `:id` einen Ownership-Check hat, müssen
**alle** Sub-Endpoints unter `:id/*` denselben Check haben. Eine fehlende
Zeile am Anfang eines Sub-Controllers reicht für komplette Credential-
Exfiltration über das Customer-Portal.

### Runde 12 – JWT raus aus localStorage (XSS-Resistenz)

Externer Pentest: "JWT in `localStorage` (MITTEL)". Bei einer XSS-Lücke
irgendwo in der App wäre der Token JS-erreichbar → Angreifer könnte alle
Anbieter-Credentials abrufen. Aktuell gibt's keinen bekannten XSS-Vektor
(CSP `script-src 'self'`, React-DOM-Escaping, keine `dangerouslySetInnerHTML`
außer in Admin-befüllten HTML-Templates), aber das Defense-in-Depth-Pattern
gehört auf den SPA-Branchenstandard:

- **Access-Token**: 15 min Lifetime, lebt **nur im JavaScript-Memory**
  (Modul-State in `api.ts` + `AuthContext`). Kein `localStorage` mehr.
- **Refresh-Token**: 7 Tage, im **httpOnly-Cookie** (`Secure` bei
  `HTTPS_ENABLED`, `SameSite=Strict`, `Path=/api/auth`). JS hat keinen
  Zugriff → XSS klaut **maximal** einen 15-min-Access-Token.
- **POST `/api/auth/refresh`**: liest Cookie, gibt neuen Access aus, rotiert
  Refresh-Cookie. Prüft `tokenInvalidatedAt` (Logout/Rollenänderung =
  sofortige Invalidation aller Tokens, auch des Refresh).
- **Auth-Middleware**: lehnt Refresh-Tokens (`type: 'refresh'`) als Bearer
  ab → 401 `"Falscher Token-Typ"`. Defense-in-Depth gegen Token-Confusion.
- **Axios-Interceptor**: bei 401 → Single-Flight-Refresh-Retry. Original-Request
  wird transparent wiederholt; concurrent 401s teilen sich denselben
  Refresh-Aufruf.
- **App-Start**: ruft `/auth/refresh` auf; wenn Cookie gültig → User
  automatisch eingeloggt, kein Re-Login nach Tab-Reload trotz
  memory-only Access-Token.
- **Logout**: löscht Cookie + setzt `tokenInvalidatedAt` → auch parallele
  Sessions auf anderen Geräten sind ungültig.

Live-Tests (alle ✅):

| Test | Resultat |
| --- | --- |
| Login | Cookie `HttpOnly; SameSite=Strict; Path=/api/auth` gesetzt, Access-Token im Body |
| API-Call mit Bearer | 200 |
| API-Call ohne Bearer | 401 |
| `/auth/refresh` mit Cookie | 200, rotiertes Cookie, neuer Access |
| `/auth/refresh` ohne Cookie | 401 |
| Refresh-Token als Bearer benutzt | 401 „Falscher Token-Typ" |
| Logout → `/auth/refresh` | 401 (Cookie weg, tokenInvalidatedAt gesetzt) |

---

## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)

- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
- Path-Traversal in Backup-Name (Regex blockiert)
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
- Self-grant Vollmacht via `customers/X/representatives` → 403
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert

---

## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)

- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
  (verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
  `<a href>`-Downloads ohne JS – v1.2-Item.
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
  (Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
  Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
  niedrig-Prio.
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
  bei Validation-Fehler) – Defense-in-Depth-Kandidat, aber nur Admin-
  erreichbar.
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
  erreichbar, niedrig-Prio).
- **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur
  Login + Password-Reset rate-limited. Eingeloggte User können theoretisch
  hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy
  (Nginx/Plesk haben eigene Limits) – nicht im App-Layer. Wenn nötig,
  später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP).
- **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern,
  XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via
  Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System.
- **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen
  (SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese
  Felder nicht aus User-Input – Risiko gering, Update breaking.

---

## 🚀 Production-Deployment-Checkliste

Vor dem öffentlichen Schalten muss in der Production-`.env`:

- `JWT_SECRET` rotieren: `openssl rand -hex 64`
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
  hart auf die echte Client-IP gesetzt wird (nicht angefügt) – sonst
  Rate-Limit-Bypass möglich.
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
  durchklicken.

---

## 🔄 Lazy Password-Hash-Upgrade

Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an –
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
Wochen nach Deployment.

---

## 🗨️ Lehre aus der Session

Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
User-zugänglich sei – war's nicht), übersehen aber, dass ein
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).

**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
Vor jedem Launch mit echten Tokens probieren.

---

## 📑 Commit-Historie

| Commit    | Runde   | Hauptthema                                                     |
| --------- | ------- | -------------------------------------------------------------- |
| (mehrere) | 1 + 2   | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md         |
| (mehrere) | 3       | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
| `334c408` | 4       | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler             |
| `8be9bae` | 5       | Uploads-Auth + Login-Timing + XSS                              |
| `4e91d96` | 6       | Customer-List-Leak + XFF-Bypass + Auth-Toggle                  |
| `12b9abe` | 7       | SSRF-Schutz + Logout                                           |
| `d063d67` | 8       | DNS-Rebinding + Per-File-Ownership                             |
| `c9a2b9f` | 9       | `npm audit fix` + Audit-Chain-Rehash + Doku                    |
| (folgt)   | 10      | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI)      |

---

## 📝 Bewusste Akzeptanz – URL-encoded Route-Parameter

**Finding aus Pentest-Runde 85 (INFO, kein Security-Impact):**

`/api/stressfrei-emails/%31/...` (URL-encoded `1`) liefert dieselbe
Antwort wie `/api/stressfrei-emails/1/...` – die `requireIdParam`-
Validierung mit `/^\d+$/` sieht die schon dekodierte Form, weil
Express URL-Parameter **vor** dem Routing dekodiert.

**Wir akzeptieren das als by-design:**

1. RFC 3986 fordert genau dieses Verhalten: prozentual-codierte und
   roh-Form derselben Zeichen müssen semantisch identisch behandelt
   werden. `/1` und `/%31` ist HTTP-konform äquivalent.
2. Ein nachträglicher Strict-Check auf `req.url` (roh) würde
   legitime Clients brechen – Browser-Bookmarks mit URL-Encoding,
   curl-Calls mit `--data-urlencode`, Proxy-Tools die URL-encoden,
   Mobile-Clients mit kanonischer URL-Normalisierung.
3. Der Pentester selbst bestätigt: **kein Security-Impact** – die ID
   landet nach Dekodierung als gültige Zahl bei der gleichen
   Auth-/IDOR-Logik, die auch für die rohe Form greift. Insbesondere
   `canAccessStressfreiEmail` läuft identisch.
4. Validierungs- und Access-Control-Kette ist nach der Dekodierung
   identisch dicht – Auth, Ownership, Rate-Limit, alles greift gleich.

**Code-Notiz:** kein Patch nötig. Diese Markdown-Sektion ist die
einzige Dokumentation, damit das in zukünftigen Pentest-Runden nicht
erneut als „offenes Finding" auftaucht.

---

## 🔒 Runde 86 – Vertrags-Identifier-Validierung

**Findings (beide LOW):**

- **R86.1**: Strings >999 Zeichen in `orderNumberAtSalesPlatform` / den
  vier verwandten Sales-/Provider-Nummern-Feldern endeten mit
  generischem 500 (DB-Overflow `VARCHAR(191)`) statt sauberem 400.
- **R86.2**: Attribut-Injection-Payload `foo" onerror="alert(1)`
  (kein umschließender Tag) überlebte `stripHtml`. React escaped
  Attribute, aber sobald der Wert in PDF-/Mail-/CSV-Export fließt,
  ist es potentiell aktiv.

**Fix:** `validateContractIdentifier(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):

- Max-Länge 100 Zeichen (deutlich unter VARCHAR(191)).
- Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$` – Buchstaben, Ziffern,
  Punkt, Bindestrich, Schrägstrich, Unterstrich und literales
  Leerzeichen. Bewusst NICHT `\s` (kein CRLF/Tab → kein
  Header-Injection-Vektor in CSV-/Mail-Exporten).
- Bei Verstoß: `ApiError(400, …)` mit konkreter Fehlermeldung
  statt 500.
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
  fünf Identifier-Felder (`customerNumberAtProvider`,
  `contractNumberAtProvider`, `orderNumberAtSalesPlatform`,
  `customerNumberAtSalesPlatform`, `contractNumberAtSalesPlatform`)
  bei jedem Create/Update.
- Frontend: `maxLength={100}` als zusätzliche UX-Schicht im
  ContractForm – Server-seitige Validierung bleibt die einzige
  Wahrheit, das HTML-Attribut spart nur den unnötigen Round-Trip.

---

## 🔒 Runde 87 – Whitelist vor Sanitizer (silent-mutation-Schutz)

**Finding (LOW): Sanitizer-Order maskiert Tag-Verstöße**

Im ursprünglichen R86-Fix lief `stripHtml(body)` **vor**
`validateContractIdentifier`. Das hatte einen subtilen Bypass:

| Payload                              | Status     | Tatsächlich gespeichert |
|--------------------------------------|------------|-------------------------|
| `<b>bold</b>`                        | 200 OK     | `"bold"` (silent strip) |
| `EVN<b>2024</b>`                     | 200 OK     | `"EVN2024"`             |
| `<script>alert(1)</script>`          | **200 OK** | `null` – **vorherigen Wert überschrieben** |
| `foo<bar>baz`                        | 200 OK     | `"foobarbaz"`           |

Kein direkter XSS-Vektor (React + DB-Whitelist greifen weiterhin),
aber zwei reale UX-/Datenintegritäts-Risiken:

1. Admin tippt `VG<2024>001`, bekommt 200 zurück, gespeichert ist
   `VG2024001` ohne Hinweis auf die Mutation.
2. Werte die komplett aus Tags bestehen (`<script>…</script>`)
   werden vom Sanitizer auf den leeren String reduziert →
   `null` in der DB → **vorheriger Wert wird stillschweigend
   gelöscht**.

**Fix:** Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht – `validateContractIdentifier` läuft jetzt **direkt
gegen den Raw-Input**, ohne dass `stripHtml` ihn vorher
glättet. Die strikte Whitelist
`^[A-Za-z0-9_\-/. ]{0,100}$` lehnt sowieso alles ab, was
`stripHtml` normalerweise abgefangen hätte (Tags, Schemes,
Zero-Width-Chars, Homoglyphe, Percent-Encoding) – Defense-in-
Depth bleibt unverändert, nur jetzt ehrlich (400 statt silent-200).

Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backend/src/controllers/contract.controller.ts)
`sanitizeContractBody`.

---

## 🔒 Runde 89 – Provider-Adressfelder härten

**Findings (R89.1 MEDIUM + R89.2 LOW):**

Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden
`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)`
geleitet. Zwei Probleme:

- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 – 501+ Zeichen
  wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert.
- **R89.2**: stripHtml lief vor dem Length-Check – derselbe Bug wie R87.
  `<script>…</script>` → leerer String → `null` in der DB → vorheriger
  Wert ohne Fehlermeldung überschrieben.

**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):

- Validiert den Raw-Input direkt – kein stripHtml davor.
- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung.
- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` – erlaubt ist
  nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle
  anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor
  für CSV-/Mail-Exporte und in einer Postadresse nie legitim.
- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor
  mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt.
- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen).

Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die
übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch
`isValidEmail` / `sanitizePhoneField` – die hat der Pentester explizit
als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt).

**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`).
Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped
korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil
einer mehrzeiligen Postadresse.

---

## 🔒 Runde 95 – Portal-Username-Validierung

**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**

`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
Validierung. Drei nachweisbare Effekte:

- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
  gespeichert → Header-Injection-Vektor sobald der Wert in
  Mail-Templates oder PDF-Footers landet.
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
  `stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
  auf neuem Feld).
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
  statt sauberem 400.

**Fix:** `validatePortalUsername(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):

- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
  CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
  → R95.1 und R95.3 ohne extra Check.
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
- Raw-Input direkt validiert (kein `stripHtml` davor) – gleicher
  R87-Pattern wie bei Contract-Identifier und Provider-Address.
- Eingehängt in `sanitizeContractBody` als eigener Branch.

**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**

Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
Modus hängt sowieso an einer schon validierten Email-Stammdate
(`assertValidForwardingEmail`).

---

## 🔒 Runde 120 – KRITISCH: IDOR auf Vertragsbaum-Endpunkt (Live-Pentest-Fund)

**Finding (CRITICAL):** Live nachgewiesen – ein Portal-User (Max,
`customerId=1`) konnte per
`GET /api/contracts?tree=true&customerId=2&includeDeactivated=true`
den vollständigen Vertragsbaum eines beliebigen Fremdkunden (Erika,
`customerId=2`) auslesen: fremder Name, Kundennummer, Vertragsnummern,
Provider und Tarife – HTTP 200.

**Ursache:** Der `tree=true`-Zweig in `getContracts` returnte **früh**,
bevor die Portal-User-`customerIds`-Filterung griff, die für die flache
Vertragsliste weiter unten im selben Handler läuft. Der Zweig prüfte
schlicht nie, ob die angefragte `customerId` zum eingeloggten Portal-User
gehört. Vorbestehender Bug (nicht durch das R120-Feature „Deaktivierte
anzeigen" eingeführt) – der neue `includeDeactivated`-Toggle hat ihn nur
sichtbarer gemacht, weil dann sogar archivierte Fremdverträge
mitkamen. Gegentest ohne den Param: identisches Leck.

**Fix:** `canAccessCustomer(req, res, customerId)` vor dem frühen Return
in [`contract.controller.ts`](../backend/src/controllers/contract.controller.ts).
Prüft eigene Customer-ID + vertretene Kunden MIT Live-Vollmacht
(`hasAuthorization`) und sendet selbst die 403. Nicht-Portal-User (Staff)
passieren unverändert. Gleiches Defense-in-Depth-Muster wie Pentest 56.3
bei `updateContract`/`deleteContract`.

**Zusatzbefund (Pentester):** In einem Staging-Vertrag lag ein
`<script>`-Test-Artefakt in `providerName`/`tariffName`. Nicht
exploitierbar – keine der 9 `dangerouslySetInnerHTML`-Stellen im Frontend
rendert Vertrags-Provider-/Tarif-Namen, React escaped sie als Text.
Schreibpfad entschärft neue Werte zusätzlich per `sanitizeContractBody`
(`stripHtml`); der Altwert stammt aus DB-Direkteingabe.

**Lessons Learned (Pentester-seitig):** Der Fund verzögerte sich, weil
zunächst gegen den falschen Host getestet wurde
(`kundencenter.hacker-net.de` = PROD statt
`kundencenter-stage.stressfrei-wechseln.de`). Auf dem korrekten
Staging-Host reproduzierte sich das Finding sofort.

**Nachtrag – Zwei-Stream-Logging der Abwehr:** Beim Retest fiel auf, dass
der abgewehrte Zugriff zwar im **SecurityEvent**-Monitoring-Stream landet
(`ACCESS_DENIED`, MEDIUM, sichtbar unter
`GET /api/monitoring/events?type=ACCESS_DENIED`), aber **nicht** im
AuditLog – dort hatte der Pentester zuerst gesucht. Der Monitoring-Stream
ist zudem über `DELETE /api/monitoring/events` löschbar und nicht
hash-verkettet. `emitAccessDenied` schreibt daher jetzt **zusätzlich**
einen tamper-evidenten AuditLog-Eintrag (`action: READ`,
`resourceType: 'AccessDenied'`, Sensitivity HIGH), der ein Clearen des
Monitoring-Streams überlebt und über die Hash-Kette manipulationssicher
ist. Gilt für alle `canAccess*`-403 (Contract + Customer, jeweils inkl.
Vollmacht-fehlt-Fall).

---

## 🔒 Runde 110 – Mass-Assignment-Whitelist auf 7 Katalog-Endpunkten

**Finding (MEDIUM):** Der Pentester hat live nachgewiesen, dass
`PUT /api/stressfrei-emails/:id` beliebige Model-Felder ausserhalb
des TS-Types (z.B. `provisionError`, `isProvisioned`,
`emailPasswordEncrypted`) durchschrieb. Beim „Rumstochern" derselbe
Bug auf sechs weiteren Update-Endpunkten gefunden: `platform`,
`tariff`, `contractCategory`, `cancellationPeriod`,
`contractDuration`, `email-providers`. Gleiche Bug-Klasse wie
das schon gefixte M1-Finding (Settings Mass Assignment), nur an
sieben weiteren Stellen.

Praktische Ausnutzung braucht Staff mit der entsprechenden
Update-Permission – kein Portal-User-Vektor, kein Cross-Customer-
Leak. Aber ohne Whitelist könnte ein kompromittierter Staff-Token
z.B. `emailPasswordEncrypted` überschreiben, ohne einen sichtbaren
Audit-Trail durch den regulären Passwort-Set-Flow.

**Fix:** Sieben Field-Whitelists + `pickXxxUpdate()`-Helper in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts),
eingehängt in die jeweiligen Update-Controller. Nur die vom
Service-Interface deklarierten Felder passieren. Reuse der schon
bewährten `pick()`-Infrastruktur (Customer/User haben denselben
Mechanismus seit Runde 7).

**Ausnahme EmailProvider:** `stripHtmlFromStrings` ist dort AUS.
Grund: das Payload enthält Passwörter und API-Keys, die legitime
Sonderzeichen wie `<`/`>` enthalten dürfen. Whitelist-Filter greift
weiterhin – XSS-Risiko minimal, da der Endpunkt admin-only ist und
die Werte in Server-Config-Feldern landen, nicht in User-Notes.

---

## 🔒 Runde 102 – Interne Vertragsnummer nachziehen

**Finding (INFO, kein Security-Impact ohne Admin-Login):**

`contractNumber` – die interne CRM-Vertragsnummer – hatte im
Update-Path keine Whitelist-Validierung. Sie wird beim Create
serverseitig via `generateContractNumber` erzeugt, aber
`updateContract` reicht den ganzen Body per `...contractData`
an Prisma durch. Ein Admin (oder Angreifer mit gestohlenem
`users:update`+`contracts:update`-JWT) konnte einen `<script>`-
Payload im `contractNumber`-Feld senden – `stripHtml` machte
daraus stille einen leeren String, die `@unique`-Constraint
akzeptierte den ersten leeren Eintrag → Vertragsnummer silent
überschrieben (R87.1-Pattern auf neuem Feld).

**Fix:** `contractNumber` zusätzlich in
`CONTRACT_IDENTIFIER_FIELDS` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts).
Damit greift automatisch dieselbe Whitelist
`^[A-Za-z0-9_\-/. ]{0,100}$` gegen Raw-Input, die schon für die
fünf Anbieter-/Vertriebsplattform-Nummern läuft. Legitime
generierte Nummern (`GAS-MPVJ4P10FE8`) bleiben grün, `<script>`,
Overflow (>100), CRLF & Tag-Attribute → 400 mit klarer Meldung.

Kein separater Migration- oder Frontend-Change nötig – der Fix
nutzt den bestehenden R86-Mechanismus.

---

## 🧭 Wann ist „dicht" dicht?

100 % gibt es nicht. Erreicht ist:

1. **Mehrere Audit-Methoden durch** – statisches Code-Review, parallele
   Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓
2. **OWASP-Top-10 explizit getestet** – Auth, Access-Control, Injection,
   Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓
3. **Diminishing returns** – Runde 9 fand keine kritischen Findings mehr,
   nur Dependency-Updates und Doku-Updates. ✓
4. **Production-Deployment-Checkliste klar.** ✓
5. **Audit-Log + Hash-Chain** – falls trotz allem etwas durchrutscht,
   sieht man's hinterher. ✓

Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`),
neue Angriffsklassen, Server-Misconfig in Production, Social Engineering.
Dafür gibt's keine Code-Lösung – nur Monitoring und Rotation der Secrets.
