dorfteich/docs/vs-nfd/10-ist-aufnahme.md
Claude Opus 5 fd07f716f6
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts)
Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings,
all verified against the code), the prioritized action plan rev. 2 with
issue references written back to every checkbox, the two-stage issue/ADR
brief, and the full reviewed draft used to create the forge state.

Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture
decisions: no security base functions (par. 52 VSA anchor), HKDF token
key separation, external authentication, page classification, read-access
audit trail (variant A), reproducible offline deployment, plugin trust
model, and backup target restriction.

Forge state created alongside this commit: 11 labels, milestones M24-M31,
issues #188-#236 (docs-only change, no code touched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 01:48:05 +02:00

468 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VS-NfD-Ist-Aufnahme Dorfteich
> Auftrag: `docs/vs-nfd/00-analyse-auftrag.md`. Read-only-Analyse — keine
> Codeänderungen.
> Stand: 2026-07-29, Code-Stand `main` = `32c8baa` (Prod v0.12.0).
> Nachgezogen **nach** dem Maßnahmenplan (`20-massnahmenplan.md`) und dem
> Issue-Entwurf (`31-issue-entwurf.md`): der Plan entstand aus einer
> Analyse, die nie als Datei abgelegt wurde. Alle Befunde hier sind am
> Code verifiziert; wo ein Befund den Plan korrigiert, steht es dabei.
Leitprinzip der Bewertung: Dorfteich darf **keine Sicherheitsgrundfunktion
im Sinne von §52 VSA selbst implementieren**. Verschlüsselung,
Authentisierung, Netzabschluss und Datenträgerschutz gehören auf die
Plattform der Behörde. Jede Stelle, an der die Anwendung selbst schützt
statt zu delegieren, ist ein Befund.
Bewertung: **OK** · **ANPASSEN** · **BLOCKIEREND** · **UNKLAR**.
Aufwand: **S** ≤ 1 AT · **M** 23 AT · **L** ≥ 4 AT.
---
## 1. Management-Zusammenfassung
Dorfteich ist näher am Ziel als bei einem Wiki dieser Größe zu erwarten
wäre — aber aus einem Grund, der genau benannt werden muss: Es _erbringt_
kaum Sicherheitsgrundfunktionen, weil es sie schlicht nicht hat. Keine
Inhalts- oder Backup-Verschlüsselung, kein eigenes MFA, keine Krypto
jenseits von Argon2id-Credential-Hashing und kurzlebigen Token-Signaturen.
Das ist unter §52 VSA die _richtige_ Architektur, nicht eine Lücke — und
so ist es zu vertreten.
Die drei größten Brocken:
1. **Authentisierung liegt vollständig in der Anwendung.** Es gibt keine
OIDC-Anbindung (nur den vorbereiteten `UserIdentity.provider`-Slot) und
keinen Weg, die lokale Anmeldung abzuschalten. Solange das so ist,
_ist_ Dorfteich Träger einer Sicherheitsgrundfunktion.
2. **Es gibt kein Einstufungskonzept.** Seiten haben kein
Einstufungsmetadatum, und kein Ausgabekanal kennt einen Aufdruck —
Print-CSS fehlt sogar komplett. Kennzeichnung ist die eine
VS-NfD-Anforderung, die niemand außer der Anwendung erfüllen kann.
3. **Der Offline-/Airgap-Betrieb ist plausibel, aber unbelegt.** Keine
Telemetrie, keine CDNs, keine Update-Checks, Fonts self-hosted, drawio
vendored — nachweislich. Getestet wurde es nie, und alle Images hängen
an Tags statt an Digests (`gotenberg/gotenberg:8` ist ein gleitender
Major-Tag).
Nebenbefunde mit Substanz: Rohdokument-Bytes jedes Im-/Exports bleiben
unbefristet in `conversion_jobs` liegen, der Volltextindex enthält
Papierkorb-Inhalte, getrashte Teiche werden nie endgültig gelöscht, und
Seiteninhalte landen als Yjs-Kopie in der IndexedDB des Endgeräts.
**42 Befunde: 5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK.**
---
## 2. Befundtabelle
Sortiert nach Bewertung. Die Spalte „Handlungsbedarf" nennt in Klammern
das Issue aus `31-issue-entwurf.md`, das den Befund adressiert.
### BLOCKIEREND
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
| ---- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----- |
| I-01 | Fremdauthentisierung fehlt | `apps/api/prisma/schema.prisma:562577`; kein `oidc`-Treffer in `apps/api/src` | `UserIdentity.provider` ist als `"password"` heute / `"oidc:<issuer>"` später dokumentiert. Implementiert ist nur `password`. ADR 0007 erklärt Bereitschaft, nicht Funktion. | OIDC Auth Code + PKCE gegen den vorhandenen Slot (#214) | L |
| I-02 | Lokale Auth nicht abschaltbar | `apps/api/src/auth/`, `apps/api/src/settings/instance-settings.service.ts` | Kein `auth.local.enabled`. Muster für harte Schalter existiert (`api.enabled`, `mcp.enabled`, beide Default aus), wird für Auth aber nicht genutzt. Betroffen sind auch Reset, Registrierung, PATs, Feed-Tokens. | Harter Schalter über alle Credential-Flows (#216) | M |
| I-03 | Kein Einstufungsmetadatum | `apps/api/prisma/schema.prisma:263301` | `Page` trägt Titel, Slug, Baumposition, Zeitstempel — kein Feld, das ein Schutzniveau ausdrücken könnte. Labels sind kein Ersatz (I-03a im Detailteil). | Enum-Feld + Vererbung + Herabstufungsrecht (#204, #205) | M |
| I-04 | Kein Einstufungsaufdruck in Ausgaben | `apps/web/src` (kein `@media print`), `apps/api/src/import-export/pdf-html.ts:7679`, `pandoc.converter.ts`, `export-markdown.ts`, `apps/api/src/public/html-shell.ts` | Kein Kanal kennt einen Aufdruck. Print-CSS fehlt **vollständig** — Browserdruck reproduziert die Bildschirmansicht inkl. Navigation. PDF hat einen Kopf **einmalig** statt je Seite; pandoc läuft ohne Reference-Doc, also ohne Kopf-/Fußzeile. | Sieben Kanal-Issues (#206#212) | L |
| I-05 | Airgap unverifiziert, Images nur per Tag | `deploy/compose/docker-compose.yml:186,206,221,237`; `.gitea/workflows/ci.yml` | `postgres:17.5-alpine`, `pandoc/core:3.6`, `gotenberg/gotenberg:8`, `caddy:2.10-alpine` — Tags, keine Digests; `gotenberg:8` gleitet über Minor/Patch. Kein Mirror-Verfahren, kein Offline-Update-Pfad, kein Testlauf. | Digest-Pinning, Mirror, Offline-Build, isolierter Testlauf, Update-Pfad (#203, #218#221) | L |
### ANPASSEN
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
| ---- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----- |
| I-06 | Ein Secret für zwei Zwecke | `packages/shared/src/env.ts:55,142`; `apps/collab/src/index.ts:46`; `apps/api/src/pages/pages.service.ts:383`; `apps/api/src/notifications/digest.service.ts:161`; `notifications.controller.ts:51` | `COLLAB_TOKEN_SECRET` signiert Collab-Tokens **und** Unsubscribe-Tokens. Teil-Trennung existiert textuell über ein `PURPOSE`-Präfix (`unsubscribe-token.ts:14`), nicht über Schlüssel. | HKDF-Subkeys je Zweck (#188) | L |
| I-07 | Eigenbau-HMAC-JWT | `packages/shared/src/token-crypto.ts:1,37,74` | Kompakter HS256-JWT handgebaut auf `node:crypto`; nur HS256, Signatur in konstanter Zeit vor jedem Lesen ungeprüfter Felder. Sorgfältig — aber handgeschriebene Krypto in der Vertrauensgrenze. Grund ist dokumentiert: identischer Code in CommonJS-api und ESM-collab. Weder `jose` noch `jsonwebtoken` ist Dependency. | Ersatz durch `jose`, Cross-Runtime-Test als Zaun (#188) | L |
| I-08 | CSRF lässt fehlende Header durch | `apps/api/src/auth/auth.guard.ts:108112` | `assertSameOrigin` nimmt `origin ?? referer`; fehlen **beide**, kehrt die Prüfung ohne Entscheidung zurück. Kommentar nennt die Absicht (Nicht-Browser-Clients; SameSite als eigentliche Abwehr). Fail-open bleibt es trotzdem. | Fail-closed mit dokumentierter Ausnahme für Bearer-Clients (#189) | S |
| I-09 | Session 30 Tage, hart kodiert | `apps/api/src/auth/sessions.service.ts:8,31,50`; `apps/api/src/auth/auth.guard.ts:58` | `SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // sliding 30 days`, bei jeder Berührung erneuert; Cookie-`maxAge` gleich. Kein Idle-Timeout — `lastSeenAt` wird geschrieben, aber nie als Grenze ausgewertet. Nicht konfigurierbar. | Absolute + Idle-Grenze konfigurierbar, Default deutlich darunter (#190) | M |
| I-10 | Feed-Token im Query-Parameter | `apps/api/src/public/public.controller.ts:28,43`; `feed.service.ts:25` | Langlebiges Lese-Credential als `?token=…` — landet in Proxy-Logs, Historie, Referrer. Speicherung ist gehasht (`feed-tokens.service.ts:73`), das Problem ist der Transport. Kein Instanzschalter für Feeds. | Token aus dem Query holen oder `feeds.enabled` (#191) | M |
| I-11 | Backup-Ziel frei wählbar | `apps/api/src/settings/instance-settings.service.ts` (`backup.nextcloud.baseUrl`); `apps/api/src/backup/backup-target.service.ts:3,65`; `apps/api/src/admin/backup-admin.service.ts:27,112,146`; `apps/backup/src/mirror.ts` | WebDAV-Ziel ist als URL validiert, aber auf keinen Host beschränkt; zweiter Remote-Pfad ist der rsync-Mirror. Keine Allowlist, kein Deploy-Kill-Switch. Wer Site-Admin hat, kann eine Vollkopie der Instanz umleiten. | Deploy-Allowlist, leere Liste = nur lokal (#192) | M |
| I-12 | Kein Pond-Purge | `apps/api/prisma/schema.prisma:175,186,277,432,553`; `apps/api/src/trash/trash.service.ts` | `Pond.deletedAt` bildet den Teich-Papierkorb; endgültiges Löschen gibt es **nur für Seiten** (`purgeNow:96`, `purgeDuePages:104`, `purgePage:121`). `Page.pond`, `Attachment.pond` und `Label.pond` haben keine `onDelete`-Aktion → Prisma-Default `Restrict` blockt das Löschen ohnehin. Getrashte Teiche bleiben unbegrenzt liegen. | Pond-Purge über alle abhängigen Tabellen (#193) | M |
| I-13 | Kein Orphan-File-Sweep | `apps/api/prisma/schema.prisma:530538`; registrierte Jobs in `apps/api/src/*/*.module.ts` | Das Schema sagt es selbst: der `pageId`-Link wird nicht angefasst, wenn ein Bild später aus dem Inhalt entfernt wird — „an orphan-file sweep … is a separate future maintenance job"; „`deletedAt` stays unused for now". Von fünf registrierten Jobs (`version-thinning`, `page-compaction`, `trash-purge`, `data-export-purge`, `notification-digest`) ist keiner der Sweep. | Sweep implementieren, `deletedAt` nutzen oder entfernen (#194) | M |
| I-14 | Papierkorb im Suchindex | `apps/api/src/search/postgres-search.provider.ts:41,73,142143` | Der gewichtete `tsvector` liegt auf `page_content_cache.search_vector`; getrashte Inhalte bleiben **im Index** und werden nur query-seitig ausgeblendet (`p.deleted_at IS NULL`, `po.deleted_at IS NULL`). Ein künftiger Abfragepfad ohne diesen Filter leakt Inhalt. | Vektor beim Trashen leeren, Query-Filter als zweite Ebene behalten (#195) | M |
| I-15 | Keine Retention für `audit_log` | `apps/api/prisma/schema.prisma:7491`; `apps/api/src/audit/audit.service.ts` | Persistenter Trail (`AuditEntry`, Issue #86) wächst unbegrenzt; kein Pruning-Job registriert. | Konfigurierbare Aufbewahrung + Job (#196) | S |
| I-16 | Keine Security-Response-Header | `apps/api/src/main.ts`; `apps/api/package.json` | `helmet` ist keine Dependency und kommt in `apps/api/src` nicht vor. `app.enableCors()` wird **nicht** aufgerufen — CORS ist damit implizit restriktiv (keine CORS-Header, Browser blockt cross-origin), aber nirgends als Entscheidung festgehalten. Der Web-Tier hat eine strenge CSP; die api-Antworten selbst sind die Lücke. | HSTS, `X-Content-Type-Options`, `Referrer-Policy`, Frame-/Permissions-Policy, CORS explizit (#197) | S |
| I-17 | Kein SBOM, kein Lizenzreport | `.gitea/workflows/ci.yml`; kein `sbom`/`syft`/`cyclonedx` in `.gitea/` oder `package.json` | CI macht install, build, lint, typecheck, test, `i18n:check`. Kein Lieferketten-Artefakt. (Die Lizenzlage selbst ist unkritisch, s. I-37.) | CycloneDX je Image + Workspace, Lizenzreport als Artefakt (#202) | M |
| I-18 | Keine Attachment-Integritätshashes | `apps/api/prisma/schema.prisma:541559`; `apps/api/src/files/file-storage.service.ts:21,25` | `Attachment` hat keine Checksumme; die einzigen Hashes im Schema sind Credential-/Token-Hashes. Dateien liegen unter `UPLOADS_DIR/<pondId>/<fileId>`. Manipulation am Dateisystem ist nicht erkennbar. | SHA-256 beim Upload, Prüfung beim Download, Backfill (#199) | M |
| I-19 | Plugins nicht instanzweit abschaltbar | `apps/api/src/settings/instance-settings.service.ts`; `apps/api/src/public-api/public-api.guard.ts:64`; `apps/api/src/mcp/mcp.controller.ts:50,84` | Für Public-API und MCP gibt es harte Schalter mit 404-Semantik, für Plugins nicht. Plugin-Zustand ist installierte Menge + Freischaltung je Teich. „Codeausführung in der VS-Zone" ist damit nicht mit einem Schalter beantwortbar. | `plugins.enabled = false` nach demselben Muster (#200) | M |
| I-20 | Ereigniskatalog nicht stabil | `apps/api/src/audit/audit.service.ts:9`; 37 Aufrufstellen in `apps/api/src` | `AuditEvent.action: string` — der Doc-Kommentar nennt es „stable dot-namespaced id", erzwungen wird nichts. 34 verschiedene Ids sind in Gebrauch (`auth.login_failed`, `grant.created`, `plugin.installed`, `settings.changed`, …). Ohne Vertrag brechen SIEM-Regeln beim Update. | Typisierte Union + veröffentlichter Katalog + Zaun-Test (#201) | L |
| I-21 | Keine Lesezugriffsprotokollierung | `apps/api/src/audit/audit.service.ts` | Bewusst begrenzt: „Content activity (pages, files, exports, labels) intentionally stays log-only — the trail answers 'who changed access/configuration', not 'who edited what'." Lesezugriffe existieren gar nicht. Das einzige `action: 'read'` (`apps/api/src/mcp/mcp.service.ts:243`) ist ein Permission-Parameter, kein Ereignis. | Lesetrail nur für eingestufte Inhalte, Variante A (#222#225) | L |
| I-22 | Rohdokument-Bytes ohne Pruning | `apps/api/prisma/schema.prisma:746784`; `apps/api/src/import-export/import-export.module.ts:62` | `ConversionJob.input`/`result` sind die **rohen Dokumentbytes** jedes Im-/Exports. Das Schema nennt sie „transient, not the durable copy" und verweist auf „a later maintenance job". Registriert ist nur `data-export-purge`, und `expiresAt` ist laut Schema „Null for every other job kind, whose result never expires". Für `export_docx`/`import_docx` bleiben die Bytes damit unbefristet. | Pruning für alle Job-Arten; im Löschkonzept (#229) ausweisen | M |
| I-23 | `mail_outbox` ohne Retention, mit Seitentiteln | `apps/api/prisma/schema.prisma:683697`; `apps/api/src/notifications/digest.service.ts:16,125,146` | Die Outbox speichert `textBody`/`htmlBody` dauerhaft; kein Pruning-Job. Digest-Mails enthalten **Seitentitel** und Akteursnamen (`- ${page.pageTitle}: … (${actorNames})`). Transaktionsmails selbst tragen keinen Inhalt (`mail-templates.ts:2544`: Anrede + i18n-Text + Link). | Retention für `mail_outbox`; im Löschkonzept ausweisen | S |
| I-24 | Slug-Residuum nach Purge | `apps/api/prisma/schema.prisma:441461` | `PageLink.fromPage` kaskadiert, `toPage` ist `onDelete: SetNull` — nach dem Purge bleibt in fremden Seiten eine Zeile mit `target_slug` der gelöschten Seite (`toPageId` genullt). Der Titel/Slug einer eingestuften Seite kann selbst eingestufte Information sein. | Bewusst entscheiden: Zeilen mitlöschen oder als Restrisiko (#231) führen | S |
| I-25 | Inhaltskopie auf dem Endgerät | `apps/web/src/editor/use-collab-provider.ts:32,76,134141,184` | Jede geöffnete Seite wird als Yjs-Dokument in die IndexedDB gespiegelt (`dorfteich-page-<pageId>`, Issue #38, ADR 0003). Beim Verlassen wird sie gelöscht, **wenn** der Server synchronisiert hat (`clearData()`); bei unsynchronisierten Offline-Änderungen bleibt sie absichtlich stehen. Bricht der Browser vorher ab, läuft die Aufräum-Routine nicht. Für Entzugsfälle gibt es `discardLocal` (#39). | Nicht „keine Spuren auf dem Endgerät" behaupten; Endgeräteverschlüsselung als Betreiberpflicht dokumentieren (#226, #231) | S |
| I-26 | Node-Version nicht gepinnt | `package.json:710` | `"engines": { "node": ">=22" }` ist eine Untergrenze, kein Pin; `packageManager: "pnpm@11.9.0"` ist exakt. Für einen reproduzierbaren Offline-Build fehlt die Node-Festlegung. | Node-Version pinnen und im Build-Verfahren nennen (#219) | S |
### UNKLAR
| Nr. | Thema | Fundort | Ist-Zustand | Warum unklar |
| ---- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| I-27 | Herkunft/Maintainer-Status der Abhängigkeiten | `pnpm-lock.yaml` (1380 aufgelöste Pakete, 366 davon prod) | Lizenzlage geprüft (I-37), Jurisdiktion nicht. | Die npm-Registry weist keine Rechtsträger oder Jurisdiktionen aus. Eine belastbare Antwort braucht Recherche je Paket; für die Kernpakete (React/Meta, TipTap/überwiegend DE-Umfeld, Yjs, NestJS, Prisma) ist sie machbar, für 366 Pakete nicht mit vertretbarem Aufwand. Vorschlag: Kernabhängigkeiten einzeln, Rest über den SBOM (#202) offenlegen. |
| I-28 | Verhalten ohne Internetzugang | s. I-05, I-31 | Kein Codepfad baut eine Verbindung nach außen auf außer Sidecars, SMTP und Backup-Ziel. | Ob eine egress-geblockte Instanz **vollständig** funktioniert, ist eine Laufzeitfrage (Container-Pulls, DNS, Zertifikatsprüfungen, SMTP-Timeouts). Nur durch den Testlauf (#220) beantwortbar. |
| I-29 | Wirksamkeit der Sicherheitsschalter bei laufendem Betrieb | `apps/api/src/settings/instance-settings.service.ts` | Der Settings-Cache der api ist in-process; ein DB-Write an `instance_settings` wirkt erst nach api-Neustart. | Für `api.enabled`/`mcp.enabled` ist das dokumentiert und betrieblich beherrschbar. Für einen künftigen `plugins.enabled` (#200) oder einen Auth-Schalter (#216) muss geklärt werden, ob „Schalter umgelegt" auch „sofort wirksam" heißt — sonst ist die Referenzkonfiguration zeitweise unwahr. |
| I-30 | Vollständigkeit der Ausgabekanal-Liste | `apps/api/src/import-export/`, `apps/api/src/public/`, `apps/api/src/public-api/`, `apps/api/src/files/` | Gefunden wurden: Web-Ansicht, Browserdruck, PDF (Gotenberg), DOCX/ODT (pandoc), Markdown-Einzeldownload, Pond-ZIP, Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse, No-JS-Shell, Anhang-Download, Datenexport (DSGVO), Backup. | Die Liste ist durch Codelesen entstanden, nicht durch eine erschöpfende Routen-Enumeration. Der vorhandene Route-Enumeration-Test des Permission-Modells wäre die Grundlage, das mechanisch zu belegen — empfohlen als Teil von #211. |
### OK — Stärken, die zu belegen sind
| Nr. | Thema | Fundort | Ist-Zustand |
| ---- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| I-31 | Keine ausgehenden Verbindungen | `apps/api/src/health/readiness.service.ts:83,102`; `apps/api/src/import-export/gotenberg.renderer.ts:59,85`; `pandoc.converter.ts:102,121`; `apps/api/src/mail/smtp-config.service.ts:54,71`; `apps/backup/src/remote.ts`, `mirror.ts` | **Jeder** ausgehende Aufruf im Code geht an einen internen Sidecar (`PANDOC_URL`, `GOTENBERG_URL`), an SMTP oder an das konfigurierte Backup-Ziel. Keine Telemetrie, keine Update-Checks, keine Analytics, kein Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed, keine Avatar-Dienste, keine externen Karten, Fonts self-hosted (ADR 0016). Das ist der stärkste Einzelbefund des ganzen Berichts. |
| I-32 | Passwort-Hashing | `apps/api/src/users/password.ts:1,925` | Argon2id über die `argon2`-Bibliothek, mit `needsRehash`-Pfad. Kein Eigenbau. |
| I-33 | Zentrales Berechtigungsmodell | `apps/api/src/permissions/`; `apps/api/src/mcp/mcp.service.ts:232253` | Default-closed, deny-wins, zentral durchgesetzt, mit Route-Enumeration-Test. Der MCP-Endpoint **divergiert nicht**: er ruft `PermissionService.hasPondRole` / `canAccessPage`; eigenständig sind nur seine Schalter. Korrigiert eine offene Frage des Maßnahmenplans. |
| I-34 | Volltextsuche ohne externe Engine | `apps/api/src/search/postgres-search.provider.ts:4041` (ADR 0010) | Postgres-`tsvector`. Kein Elasticsearch, kein zweiter Datenhalter, keine weitere Netzwerkgrenze. |
| I-35 | Single-Tenant | `apps/api/prisma/schema.prisma` (33 Modelle, kein `Tenant`/`Organization`) | Teiche sind Container innerhalb _einer_ Instanz. Passt zur empfohlenen Betriebsform „eine Instanz je Einstufungsniveau". |
| I-36 | Service Worker cached keine Inhalte | `apps/web/vite.config.ts:1220` | VitePWA precacht ausschließlich Build-Assets; **kein** Runtime-Caching, `navigateFallbackDenylist` schließt `/api` und `/collab` aus. API-Antworten landen nie im SW-Cache. |
| I-37 | Lizenzlage durchweg permissiv | `pnpm licenses list --prod` | 366 Produktionspakete, 11 verschiedene Lizenzen: MIT 301, ISC 24, Apache-2.0 22, BSD-3-Clause 5, BlueOak-1.0.0 4, BSD-2-Clause 4, MIT-0 2, Python-2.0 1, CC0-1.0 1, 0BSD 1, `(MPL-2.0 OR Apache-2.0)` 1. **Kein GPL/AGPL, kein Copyleft mit Verteilungsfolgen.** Projektlizenz MIT. |
| I-38 | Log-Redaction | `apps/api/src/app.module.ts:8493`; `apps/collab/src/logger.ts` | pino-JSON auf stdout, `LOG_LEVEL` konfigurierbar, `redact: { paths: ['req.headers.authorization','req.headers.cookie'], remove: true }`. Kein Datei- oder Syslog-Transport — Weiterleitung ist Sache der Container-Runtime, was für SIEM-Anbindung die richtige Arbeitsteilung ist. |
| I-39 | Public-API und MCP standardmäßig aus | `apps/api/src/settings/instance-settings.service.ts` (`api.enabled` / `mcp.enabled`, je `.default(false)`); `public-api.guard.ts:64`; `mcp.controller.ts:50,84` | Beide Oberflächen antworten im ausgeschalteten Zustand mit 404 (Existenz verstecken), zusätzlich muss jeder Teich einzeln zustimmen. Vorbildliches Muster für I-02 und I-19. |
| I-40 | drawio ist vendored | `packages/plugins/drawio/vendor/drawio-30.3.6/`; `packages/plugins/drawio/manifest.json` | Kein externer Editor-URL: das Manifest nennt `kind: code`, `permissions: ["blockData","ui"]`, `fallback`, `license: Apache-2.0`; `homepage` ist reines Metadatum. Der drawio-Webapp-Baum liegt im Repo. Korrigiert eine offene Frage des Maßnahmenplans (kein Ausschlusskriterium). |
| I-41 | Entzug beendet laufende Sitzungen | `apps/collab/src/index.ts:6270`; `apps/collab/src/server.ts:108125`; `apps/api/src/pages/pages.service.ts:57,384` | Collab-Tokens leben **60 Sekunden** (`COLLAB_TOKEN_TTL_SECONDS = 60`); die WS-Ebene autorisiert nur gegen Signatur und `claims.pageId === documentName`, ohne Permission-Kontext. Das ist tragfähig, weil die api bei jedem Token neu prüft und ein Grant-Entzug offene Verbindungen sofort per `pg_notify`-Access-Listener schließt (#39). Für den Lesetrail folgt daraus: die api ist der richtige Emissionsort, mit natürlicher Minutengranularität. |
| I-42 | Seiten-Purge ist vollständig | `apps/api/src/trash/trash.service.ts:121144`; Kaskaden in `apps/api/prisma/schema.prisma:114,313,344,361,373,409,469,486` | `purgePage` löscht Anhänge (Datei + Quota), Content-Cache, Update-Log und Watches explizit; Kommentare, Versionen, Mentions, Pending-Contributors, Labels-Zuordnungen und Favoriten hängen an `onDelete: Cascade`. Kindseiten werden bewusst an den Großeltern-Knoten gehoben. Residuen bleiben nur laut I-24. |
---
## 3. Detailbefunde
### 3.1 Sicherheitsgrundfunktionen
**(1) Authentisierung.** Es gibt eine eigene Benutzer- und
Credential-Haltung: `User` plus `UserIdentity` mit
`@@unique([provider, subject])`; `credential` hält den Argon2id-Hash
(`schema.prisma:562577`). Gehasht wird mit `argon2id` über die
`argon2`-Bibliothek inklusive `needsRehash` (`users/password.ts:925`) —
kein Eigenbau, korrektes Verfahren. **Eine OIDC-, SAML- oder
LDAP-Anbindung existiert nicht**; ein Suchlauf über `apps/api/src` und
`packages/shared/src` findet `oidc` ausschließlich im Schema-Kommentar.
Unterstützung für Client-Zertifikate oder vertrauenswürdige
Reverse-Proxy-Header ist ebenfalls nicht vorhanden. → I-01, I-02.
**(2) Session-Verwaltung.** Eigene Implementierung, nicht Framework:
`Session` als serverseitige Tabelle, deren `id` der SHA-256-Hash des
Session-Tokens ist (`schema.prisma:580595`, `sessions.service.ts:84`) —
ein gestohlener DB-Dump gibt keine gültigen Tokens her. Das Cookie ist
`httpOnly`, `sameSite: 'lax'`, `secure` in Produktion
(`auth.guard.ts:5258`). Gültigkeit: gleitende 30 Tage, hart kodiert,
ohne Idle-Grenze. → I-09.
**(3) Kryptographie.** Vollständige Inventur der eigenen Krypto-Aufrufe:
| Zweck | Verfahren | Fundort |
| ------------------------ | --------------------------------- | ------------------------------------------------------- |
| Passwörter | Argon2id | `users/password.ts:925` |
| Session-Ids | SHA-256 über das Token | `auth/sessions.service.ts:84` |
| Auth-Tokens (Mail-Flows) | SHA-256 | `auth/auth-tokens.service.ts:55` |
| PATs | SHA-256 | `public-api/api-tokens.service.ts:149` |
| Feed-Tokens | SHA-256 | `public/feed-tokens.service.ts:73` |
| Collab-Tokens | HS256-JWT, handgebaut, TTL 60 s | `shared/token-crypto.ts:1,37,74`; `pages.service.ts:57` |
| Unsubscribe-Tokens | HMAC-SHA-256 mit `PURPOSE`-Präfix | `notifications/unsubscribe-token.ts:14,40` |
**Nirgends werden Inhalte ver- oder entschlüsselt.** Es gibt keine
Backup-Verschlüsselung und keine Verschlüsselung in DB oder Dateisystem.
Unter §52 VSA ist das die gewollte Arbeitsteilung. Die beiden
Befunde betreffen nicht das _Ob_, sondern die Hygiene: ein Secret für zwei
Zwecke (I-06) und handgeschriebenes JWT (I-07).
**(4) Zugriffskontrolle.** Zentral in `apps/api/src/permissions/`,
default-closed mit deny-wins, per Guards durchgesetzt und durch einen
Route-Enumeration-Test abgesichert. Die geprüften Umgehungskandidaten sind
sauber: der Export filtert über `permissions.filterPages`
(`export.service.ts:102108`), die Suche joint auf lebende Seiten und
Teiche (`postgres-search.provider.ts:142143`), der MCP-Endpoint nutzt den
zentralen Service (I-33), und die Collab-WS-Ebene ist über kurzlebige
Tokens plus Entzugs-Listener abgesichert (I-41). Public-API und MCP sind
zusätzlich zweifach gegated (Instanz + Teich) und standardmäßig aus
(I-39). Ratenbegrenzung existiert als Decorator-Mechanik
(`rate-limit/rate-limit.guard.ts:24`).
**(5) Integrität.** Keine Prüfsummen auf Anhängen (I-18), keine
Signaturen auf Plugin-Bundles (`manifest.json` hat kein Hash-Feld), kein
Manipulationsschutz auf dem `audit_log`. Prüfsummen existieren nur als
Token-Hashes, also zur Authentisierung, nicht zur Integritätssicherung von
Nutzdaten.
### 3.2 Datenhaltung
**(6) Seiteninhalte** liegen ausschließlich in Postgres, aber in
**fünf** Repräsentationen:
1. `pages.ydoc_state` (Bytes) — das lebende Yjs-Dokument
2. `page_updates` — das Update-Log; gemergt erst bei Compaction
3. `page_versions.ydoc_snapshot` — vollständige, selbstständige Snapshots
(AUTO/MANUAL/PRE_RESTORE), damit ein Restore nie vom Update-Log abhängt
(`schema.prisma:318347`)
4. `page_content_cache``plain_text`, `markdown`, `html`, `outline`
(jsonb) — vier abgeleitete **Klartext**-Formen, bei jedem Save
erneuert (`schema.prisma:392411`)
5. `page_content_cache.search_vector` — der gewichtete `tsvector`
Für ein Löschkonzept ist Punkt 4 der wichtigste: der Klartext jeder Seite
liegt vierfach vor, unabhängig vom Yjs-Zustand.
**(7) Anhänge** liegen im Dateisystem unter
`UPLOADS_DIR/<pondId>/<fileId>` (`file-storage.service.ts:21,25`,
Default `./data/uploads`), mit Metadaten in `attachments`. Keine
Verschlüsselung, keine Prüfsumme.
**(8) Volltextindex:** Postgres-FTS, persistiert **auf derselben Zeile wie
der Klartext** (`page_content_cache`), GIN-Index per Raw-SQL-Migration.
Er enthält damit zwangsläufig Klartext der Inhalte — und Papierkorb-Inhalte
(I-14).
**(9) Weitere Kopien im Betrieb:**
| Kopie | Fundort | Lebensdauer |
| -------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------ |
| `conversion_jobs.input` / `.result` — rohe Dokumentbytes jedes Im-/Exports | `schema.prisma:746784` | **unbefristet** außer für Datenexport-Jobs (I-22) |
| `mail_outbox.text_body` / `.html_body` — Digest-Mails mit Seitentiteln | `schema.prisma:683697` | **unbefristet** (I-23) |
| IndexedDB je geöffneter Seite auf dem Endgerät | `use-collab-provider.ts:76` | bis zum Verlassen, bei unsynchronisierten Änderungen länger (I-25) |
| `comments`, `notifications` | `schema.prisma:100164` | an Seite/Nutzer gekoppelt, kaskadierend |
| Backups | `apps/backup/`, ADR 0015 | Retention konfigurierbar |
| PWA-Service-Worker | `vite.config.ts:1220` | **enthält keine Inhalte** (I-36) |
Thumbnails, Vorschaubilder oder serverseitige Temp-Dateien gibt es
nicht: eine Suche nach `tmpdir`/`mkdtemp`/`/tmp` in `apps/api/src` bleibt
leer — die Konverter arbeiten über HTTP gegen die Sidecars.
**(10) Revisionshistorie:** `page_versions` mit vollständigen Snapshots,
Trigger-Enum (AUTO/MANUAL/PRE_RESTORE), `contributor_ids` als Array;
angelegt von collab bei Sitzungsende und im Bearbeitungsintervall, von der
api auf Zuruf. Ausgedünnt durch den registrierten Job
`version-thinning`.
### 3.3 Löschen und Vernichtung
**(11) Seiten** werden zweistufig gelöscht: `deletedAt` setzt den
Papierkorb (ADR 0013), der Job `trash-purge` löscht nach Retention
endgültig, zusätzlich gibt es „Purge einzeln" (`trash.controller.ts:26`).
**(12) Vom Purge erfasst** (I-42): Anhänge inklusive Datei und
Quota-Rückgabe, Content-Cache (und damit der Suchvektor), Update-Log,
Watches — explizit; Kommentare, Versionen, Mentions,
Pending-Contributors, Label-Zuordnungen und Favoriten über
`onDelete: Cascade`. Kindseiten werden bewusst an den Elternknoten der
gelöschten Seite gehoben.
**(13) Rückstandsfrei? Nein — vier konkrete Reste:**
1. `page_links`-Zeilen fremder Seiten behalten den `target_slug` der
gelöschten Seite, `to_page_id` wird genullt (I-24).
2. `conversion_jobs` behalten die Rohbytes jedes Exports dieser Seite
(I-22).
3. `mail_outbox` behält Digest-Mails mit dem Seitentitel (I-23).
4. IndexedDB-Kopien auf Endgeräten, die die Seite offline geöffnet hatten
(I-25).
Für **Teiche** gibt es überhaupt kein endgültiges Löschen (I-12) — und
die FK-Restriktionen würden es derzeit auch blockieren. Backups sind ein
fünfter, gewollter Rest mit eigener Retention.
### 3.4 Ausgehende Verbindungen
**(14)(15)** Die vollständige Liste der Stellen, an denen die Anwendung
nach außen geht, ist in I-31 aufgeführt: zwei interne Sidecars (pandoc,
Gotenberg), SMTP, Backup-Ziel (WebDAV und/oder rsync). **Sonst nichts.**
Keine Update-Checks, keine Telemetrie, kein Analytics, kein
Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed,
keine Avatar-Dienste, keine externen Karten, Schriften oder Skripte.
Die gepinnten Fremdmodule laden zur Laufzeit nichts nach: drawio ist
vendored (I-40), Mermaid und Excalidraw sind gebündelte
Workspace-Pakete, und die CSP `default-src 'self'` würde einen Nachlader
ohnehin blocken.
**(16) Backups:** Ziel ist frei konfigurierbar, ohne Allowlist; nicht
verschlüsselt (I-11). Letzteres ist gewollt (§52 VSA), Ersteres nicht.
**(17) Betrieb ohne Internet:** Codeseitig nichts entgegen, empirisch
unbelegt (I-28). Offene Einzelfrage mit Betriebsrelevanz: ohne
ausgehendes SMTP funktionieren Registrierung, Verifikation und
Passwort-Reset nicht — was mit `auth.local.enabled = false` (#216)
zusammenfällt und dort mitentschieden werden sollte.
### 3.5 Ausgabekanäle
**(18)** Gefundene Wege, auf denen Inhalte die Anwendung verlassen
(Vollständigkeit s. I-30): Web-Ansicht (SPA), Browserdruck, PDF über
Gotenberg, DOCX/ODT über pandoc, Markdown-Einzeldownload
(`pages.controller.ts:122`), Pond-ZIP (`export.service.ts:68`),
Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse
(Snippets), No-JS-Shell (`public/html-shell.ts`), Anhang-Download
(`files.controller.ts:51,71`), DSGVO-Datenexport, Backup.
**(19) Metadatenmodell:** `Page` trägt Titel, Slug, `parentId`, `sortKey`,
Zeitstempel, Ersteller — nichts, wo eine Einstufung hineinpasste (I-03).
Der naheliegende Ersatz „Labels" trägt nicht, und zwar aus vier
belegbaren Gründen (**I-03a**):
- `Label` ist **teich-gebunden** (`pondId`, `schema.prisma:423437`) —
dieselbe Einstufung wäre in jedem Teich ein anderes Objekt ohne
instanzweite Bedeutung.
- Labels sind von jedem Editor **bearbeitbar**.
- Labels **vererben nicht** im Seitenbaum.
- Labels **verlassen die Anwendung nie**: `export.service.ts:98105` lädt
`labelIds` ausschließlich für `permissions.filterPages`; kein
Export-Pfad schreibt sie in die Ausgabe. Das korrigiert eine offene
Frage des Maßnahmenplans mit einem klaren Nein.
**(20) Eingriffspunkte für einen Kopf-/Fußaufdruck** — konkret:
| Kanal | Eingriffsstelle | Zustand |
| --------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Web-Ansicht | Seiten-Layout der SPA | kein Aufdruck-Element |
| Browserdruck | **fehlt komplett** — kein `@media print` in `apps/web/src` | Neubau nötig, `@page`-Randboxen für Wiederholung je Blatt |
| PDF | `import-export/pdf-html.ts:7679` (Dokumentkopf, einmalig) + Gotenberg-Footer-Template (`:31`, liefert heute Seitenzahlen) | Mechanik für „je Seite" existiert, wird für den Aufdruck nicht genutzt |
| DOCX/ODT | `import-export/pandoc.converter.ts` | **kein** `--reference-doc` — es gibt keine Kopf-/Fußzeilendefinition, in die der Aufdruck gehörte |
| Markdown-ZIP | `import-export/export-markdown.ts` | keine YAML-Frontmatter-Ausgabe (Frontmatter existiert nur im _Import_, `obsidian-vault.ts:203,528`) |
| Atom-Feeds | `public/feed.service.ts` | kein Feld |
| Public-API | `public-api/` | Seitenrepräsentation ohne Einstufung |
| Suchergebnisse | `search/postgres-search.provider.ts` | Snippets ohne Markierung |
| No-JS-Shell | `public/html-shell.ts` | eigener Renderpfad, braucht eigene Behandlung |
| Anhang-Download | `files.controller.ts:51,71` | Dateiname/Begleitdatei sind die einzigen Träger |
### 3.6 Protokollierung
**(21)** Protokolliert werden Authentisierungs- und Admin-Ereignisse
(34 Ids, s. I-20) — persistent in `audit_log` **und** als
`audit: …`-stdout-Zeile. Inhaltsaktivität bleibt bewusst log-only,
Lesezugriffe fehlen ganz (I-21).
**(22) Wohin:** pino-JSON auf stdout, kein Datei- oder Syslog-Transport
(I-38). Ein SIEM-Export ist damit über die Container-Runtime möglich und
braucht keinen anwendungsseitigen Syslog-Client — die Lücke ist nicht der
Transport, sondern der stabile Ereigniskatalog (I-20).
**(23) Aufbewahrungsdauer:** für `audit_log` **nicht** konfigurierbar und
ohne Job (I-15); für Backups konfigurierbar
(`BACKUP_RETENTION_DAYS`, `backup.localRetentionDays`,
`backup.remoteRetentionDays`); `LOG_LEVEL` konfigurierbar, Log-Retention
ist Sache der Runtime.
**(24) Unerwünschte Daten in Logs:** `authorization`- und
`cookie`-Header werden entfernt (`app.module.ts:93`); der
`AuditEvent.details`-Kommentar verlangt „never secrets, tokens, or page
content". Seitentitel und Nutzernamen erscheinen in Logs und
Digest-Mails (I-23) — für eine eingestufte Seite kann bereits der Titel
schützenswert sein. Das gehört in die Restrisikoliste, nicht in einen
Bugfix.
### 3.7 Lieferkette
**(25) Abhängigkeiten:** 1380 aufgelöste Pakete im Lockfile, davon **366
im Produktionsbaum**. Direkte Produktionsabhängigkeiten: api 31,
web 26, collab 7, shared 5, backup 5. Die Kernpakete sind
NestJS 11, Prisma 6, Postgres-Client `pg` 8, Argon2, nodemailer 9,
pino 9, Yjs 13 mit Hocuspocus 4, TipTap 3 und ProseMirror, React 19,
`@modelcontextprotocol/sdk` 1. Lizenzverteilung s. I-37 — durchweg
permissiv.
**(26) Herkunft:** nicht belastbar beantwortbar (I-27).
**(27) Pinning:** `pnpm-lock.yaml` ist eingecheckt und deckt transitiv
alle 1380 Pakete; CI installiert mit `--frozen-lockfile`. Die
Manifest-Ranges sind Caret-Ranges, was in Kombination mit dem Lockfile
korrekt ist. Ungepinnt sind: **Container-Image-Digests** (I-05) und die
**Node-Version** (I-26). `pnpm` ist exakt gepinnt. Randnotiz ohne
Sicherheitsbezug: `zod` läuft in api/shared als ^3, im Web als ^4.
**(28) Deployment:** Container-Images per Docker Compose
(`deploy/compose/docker-compose.yml`) — vier eigene Images (web, api,
collab, backup) plus `postgres:17.5-alpine`, `pandoc/core:3.6`,
`gotenberg/gotenberg:8`, `caddy:2.10-alpine` (letzteres im Profil
`caddy`). Migrationen laufen beim api-Start (`main.ts:runMigrations`,
mit Advisory-Lock und einer Warteschleife gegen laufende Restores).
Eine Offline-Installation ist plausibel und unbelegt (I-05, I-28).
### 3.8 Betriebsmodell
**(29) Mandantenfähigkeit:** Single-Tenant (I-35).
**(30) Konfiguration.** Zwei klar getrennte Ebenen:
- **Deploy-Zeit (Env):** `DATABASE_URL`, `APP_BASE_URL`, `PORT`,
`UPLOADS_DIR`, `COLLAB_TOKEN_SECRET`, `SMTP_*`, `LOG_LEVEL`,
`GOTENBERG_URL`, `PANDOC_URL`, `BACKUP_RETENTION_DAYS`,
`MIGRATE_ON_START` (`packages/shared/src/env.ts`).
- **Laufzeit (`instance_settings`):** `upload.allowedExtensions`,
`upload.svgPolicy`, `api.enabled`, `mcp.enabled`, `backup.*`
(Retention, Nextcloud-Ziel, Upload-Zeitplan), `legal.imprint`,
`legal.privacyPolicy`, `home.content`, Setup-Abschluss.
Wichtig für Sicherheitsschalter: der Settings-Cache ist in-process, eine
Änderung wirkt erst nach api-Neustart (I-29).
**(31) Hart abschaltbare Funktionen:** Public-API und MCP (instanzweit,
Default aus, plus Zustimmung je Teich, I-39); SVG-Uploads
(`upload.svgPolicy: reject`); Dateiendungen per Allowlist; Caddy per
Compose-Profil. **Nicht** abschaltbar: lokale Authentisierung (I-02),
Plugins (I-19), Feeds (I-10).
---
## 4. Offene Punkte
Was in dieser Analyse **nicht** geklärt werden konnte, mit Grund:
1. **Jurisdiktion und Maintainer-Status der 366 Produktionspakete**
(I-27) — die Registry weist es nicht aus. Vorschlag: Kernpakete
einzeln belegen, den Rest über den SBOM offenlegen.
2. **Funktionsfähigkeit ohne Internetzugang** (I-28) — nur durch den
Testlauf in netzisolierter Umgebung beantwortbar. Der Testlauf ist
deshalb selbst ein Arbeitspaket (#220), kein Nachweisdokument.
3. **Sofortige Wirksamkeit künftiger Sicherheitsschalter** (I-29) —
hängt davon ab, ob `plugins.enabled` und ein Auth-Schalter am
in-process-Cache vorbei gelesen werden. Zu entscheiden in #200/#216.
4. **Vollständigkeit der Ausgabekanal-Liste** (I-30) — durch Codelesen
erstellt, nicht mechanisch belegt. Empfehlung: über den vorhandenen
Route-Enumeration-Test absichern.
5. **Wirkung von `PRE_RESTORE`-Snapshots und Backups auf ein
Löschverlangen** — beides sind gewollte Kopien mit eigener Retention;
wie lange ein gelöschter eingestufter Inhalt darin fortlebt, ist eine
Frage der Retention-Konfiguration beim Betreiber, nicht des Codes. Für
das Löschkonzept (#229) muss die Zahl trotzdem benannt werden.
6. **Werden Suchsnippets aus dem Klartext-Cache oder aus dem `tsvector`
erzeugt?** Für die Kennzeichnungspflicht (#211) reicht der Befund
„Snippets enthalten Seitentext"; für die Frage, ob ein Treffer
Inhaltsfragmente an nicht-lesende Nutzer ausgeben könnte, wäre eine
genauere Betrachtung von `postgres-search.provider.ts` nötig. Die
Berechtigungsprüfung joint auf lebende, sichtbare Seiten — ein
Leck ist nicht ersichtlich, aber nicht abschließend geprüft.
---
## 5. Verhältnis zum Maßnahmenplan
Diese Ist-Aufnahme wurde **nach** `20-massnahmenplan.md` erstellt. Sie
bestätigt dessen Befunde bis auf drei Korrekturen und liefert vier
Ergänzungen, die im Plan fehlen:
**Bestätigt:** alle Phase-1- und Phase-2-Positionen des Plans, jeweils mit
Fundort (Tabelle in Abschnitt 2).
**Korrigiert:**
1. `deploy/compose/.env` ist **nicht** im Repo (`.gitignore:57`,
`git ls-files`) — der Punkt bleibt sinnvoll, aber als Verifikation
statt als Leck-Behebung.
2. Das **drawio-Plugin lädt keine externe URL** (I-40) — kein
Ausschlusskriterium.
3. **Labels fließen nicht in Exporte** (I-03a) — die offene Frage des
Plans ist mit Nein beantwortet, und der MCP-Gate divergiert nicht
(I-33).
**Ergänzt (im Plan nicht enthalten):**
| Befund | Warum es zählt |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| I-22 `conversion_jobs` halten Rohdokument-Bytes unbefristet | Direkt löschkonzeptrelevant: eine gelöschte eingestufte Seite lebt in ihrem letzten Export weiter. |
| I-23 `mail_outbox` ohne Retention, mit Seitentiteln | Zweite unbefristete Kopie inhaltsnaher Daten. |
| I-24 `page_links.target_slug` überlebt den Purge | Der Slug einer eingestuften Seite kann selbst schützenswert sein. |
| I-25 IndexedDB-Kopie auf Endgeräten | Inhalte verlassen den Server auf Endgeräte-Datenträger — muss in der Abgrenzungserklärung als Betreiberpflicht (Endgeräteverschlüsselung) auftauchen. |
| I-26 Node-Version nicht gepinnt | Blockiert die Reproduzierbarkeitsaussage in #219. |
Empfehlung: I-22 bis I-26 als fünf zusätzliche Issues in die Meilensteine
`M24 — VS-NfD: security quick wins` (I-22, I-23, I-24, I-26) bzw. in die
Dokumentation `M30` (I-25) aufnehmen. Das hebt den Umfang von 45 auf
**50 Issues**; Aufwand zusätzlich ca. **46 AT** (I-22 M, I-23 S, I-24 S,
I-25 in #226/#231 enthalten, I-26 S).