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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:07:46 +02:00
parent 8a1218af6a
commit d62e6c2dbe
17 changed files with 1593 additions and 2 deletions
+3 -2
View File
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-10-09 - Completed quick task 261009-of3: Anleitungen gegen Code geprueft
Last activity: 2026-10-09 - Completed quick task 261009-p0m: Sicherheitsprotokoll + CI-Scanner
Progress: [██████████] 99%
@@ -506,6 +506,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261009-dkv | Modul Dateien Etappe 2a: Teilen (Personen, Gruppen, Links nach Nextcloud-Regeln, Von mir/Mit mir geteilt), Review-Fixes, Nur-Ansehen-Ordner ohne Hochladen; Verifikation: Needs Review (Firmen-Nextcloud) | 2026-10-09 | d487a00 | [261009-dkv-modul-dateien-etappe-2a-teilen-von-datei](.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/) |
| 261009-ikt | Cert Manager Umbau: gemeinsamer Reiter Dateien (mehrere Dateien, ZIP, Text), Kette/Fullchain mit Signaturpruefung, alle Formate inkl. EC, PFX kompatibel/modern, Vorlagen fuer 6 Zielsysteme, Fehlendes Zertifikat holen (AIA), Adressschutz gehaertet, Review-Fixes (ZIP-Bombe, PEM-Scanner, Umlaut-PFX); Modul 1.2.0; Verifikation: Verified | 2026-10-09 | 4b87249 | [261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f](.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/) |
| 261009-of3 | Alle vier Anleitungen gegen Code geprueft (5 Pruefer) und nachgearbeitet; Willkommensmail-Hinweis sechs Felder | 2026-10-09 | — | [261009-of3-anleitungen-gegen-code-pruefen-und-nacha](.planning/quick/261009-of3-anleitungen-gegen-code-pruefen-und-nacha/) |
| 261009-p0m | Sicherheitsprotokoll (docs/sicherheitsprotokoll.md), CI-Sicherheitsjob (nur Bericht), ZAP-Skript + erster Lauf (intern), Abhaengigkeiten im Major (151->12, 0 kritisch), AES-GCM-Tag, schlanke Abbilder, Sicherheits-Kopfzeilen, Review-Fixes | 2026-10-09 | 8a1218a | [261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp](.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/) |
## Deferred Items
@@ -549,6 +550,6 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
Last session: 2026-10-09T09:40:00Z
Resumed: 2026-10-09 ueber /gsd-resume-work (HANDOFF eingelesen und entfernt; CI 41a5754 gruen).
Stopped at: Teilen, Cert Manager (jetzt Zertifikatsmanager) und Anleitungs-Pruefung fertig und gepusht. Naechstes: Sicherheitsprotokoll/CI-Scanner, danach Dateien-Suche.
Stopped at: Sicherheitsprotokoll/CI-Scanner fertig. Offen: oeffentlicher ZAP-Lauf (alpha-Adresse vom Dev-Host nicht erreichbar 09.10.), erster echter CI-Lauf des security-Jobs pruefen. Naechstes: Dateien-Suche.
Resume file: None
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
@@ -0,0 +1,458 @@
---
phase: quick-261009-p0m
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261009-p0m
description: "Sicherheitsprotokoll (docs/sicherheitsprotokoll.md) mit allen bisherigen Sicherheitspruefungen und der ersten Vollpruefung, CI-Job security (nur Bericht, nie blockierend) mit gepinnten und pruefsummengesicherten Scannern, ZAP-Grundpruefung gegen alpha vor Freigaben (erster Lauf in diesem Auftrag), Behebung der sicher behebbaren Baseline-Befunde (Abhaengigkeiten innerhalb der Hauptversion, AES-GCM-Taglaenge, Laufzeitabbilder ohne Entwicklungswerkzeuge), Ausnahmelisten fuer verifizierte Fehlalarme, CHANGELOG und Anleitungen"
date: 2026-10-09
files_modified:
# Task 1 — tracer: reporting-only security job end to end
- .gitea/scripts/security-scan.sh
- .gitleaks.toml
- .semgrepignore
- .gitignore
- .dockerignore
- .gitea/workflows/ci.yml
- docs/ci-cd-setup.md
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/.gitignore
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task1-runner-full.txt
# Task 2 — the protocol, links, maintenance rule
- docs/sicherheitsprotokoll.md
- docs/README.md
- docs/anleitung-betrieb.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/link-check.py
# Task 3 — ZAP baseline script, local proof, first run against alpha
- .gitea/scripts/zap-baseline.sh
- .gitea/scripts/zap-hooks.py
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/zap-testserver.py
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task3-zap-local.txt
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/zap-alpha-2026-10-09.txt
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/zap-hook-basic-auth.py
# Task 4 — dependency fixes within the current majors
- package.json
- pnpm-lock.yaml
- apps/api/package.json
- apps/web/package.json
- apps/api/src/mail/mail.module.ts
- apps/api/src/dkv/dkv-mail.service.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/desktop/src-tauri/Cargo.lock
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task4-audit.txt
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/mail-smoke.sh
# Task 5 — AES-GCM tag length, runtime images without development tooling
- apps/api/src/crypto/crypto.service.ts
- apps/api/src/crypto/crypto.service.spec.ts
- apps/api/Dockerfile
- apps/web/Dockerfile
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task5-image.txt
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/fresh-db-start.sh
# Task 6 — final run, protocol close-out, cleanup
- .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/final-run.txt
autonomous: true
requirements: [QUICK-261009-p0m]
estimate:
tokens: 440000
raw_tokens: 440000
tasks: 6
confidence: low
must_haves:
truths:
- "Every push to main and every release tag v* runs, after publish, the job security that never fails or delays quality, test, desktop or publish and receives no secret: gitleaks over the full git history, pnpm audit --prod and osv-scanner over pnpm-lock.yaml and the desktop Cargo.lock, Semgrep and Trivy over the committed source, Trivy over the freshly built api and web images; findings appear as SECURITY-SUMMARY lines in the run log and (best effort) as artifact sicherheitsberichte — proven inside gitea/runner-images:ubuntu-latest with a full run, an offline run (exit 0, every tool skipped) and the ref plan (main → :beta, v* → :live, live branch → no images); pushes to the branch live do not run it (per D-04, D-18)"
- "All scanner versions are pinned (gitleaks 8.30.1, trivy 0.75.0, osv-scanner 2.6.0, semgrep 1.180.0, pnpm 9.15.0) and every downloaded archive is checked against a SHA256 literal stored in the script before each use; a mismatch or missing network only skips that tool and the script still exits 0 (per D-04, D-16)"
- "The verified false positives (test keys of the Zertifikatsmanager, private-key snippets in three specs and one research note, an i18n label, a test value, a validation table row, a curl example against the local throwaway Nextcloud) are allowlisted in the narrowest form, so gitleaks over the full history reports 0 findings while a freshly planted fake token in a throwaway clone is still found by gitleaks and Trivy (per D-11, D-19)"
- "docs/sicherheitsprotokoll.md lists in plain German with „Sie“ every security check done so far (eight code reviews, threat models, the data-separation tests, the security-flavoured tests, the WINDOWS ledger), the first full scan of 9 October 2026 with numbers and triage, how each check works, and per finding a status behoben / bewusst akzeptiert / offen with a reason; it is linked from docs/README.md, the Betriebsanleitung and the Entwicklungsanleitung, and the Entwicklungsanleitung section „So pflegen Sie dieses Protokoll“ says that every security review, every recorded scan and every pre-release ZAP run adds an entry (per D-01, D-02, D-03, D-07)"
- "sh .gitea/scripts/zap-baseline.sh runs the pinned OWASP ZAP image passively against https://alpha.tessera.ctl.de from the dev host (traditional spider without form processing or POSTs, no AJAX spider, passive rules only), leaves Basic Auth in front of alpha untouched, is a step of „Eine Version freigeben“ in Kapitel 9 of the Betriebsanleitung, and its first run against alpha is recorded with counts and triage in the protocol (per D-05, D-06)"
- "Dependency findings with an in-major fix are fixed: Next.js 15.5.27, NestJS 11.2.x (multer 2.4.0), nodemailer 9.1.x, undici 7.29.1 or newer 7.x, adm-zip 0.6.1, csv-parse 7.0.3, pnpm overrides for transitive packages with a patched version in the same major, the two unused packages @nestjs-modules/mailer and ews-javascript-api removed, rustls 0.23.45 in the desktop lockfile; Prisma stays 6.19.3 and Next stays 15; pnpm audit --prod reports 0 critical and fewer high findings than before; both test suites, type check, lint, cargo check/clippy, the rebuilt stack and the e2e smoke scripts stay green (per D-08)"
- "CryptoService.decrypt rejects every stored value whose GCM authentication tag is not exactly 16 bytes (a truncated 4-byte tag of a real ciphertext no longer decrypts) and the api runtime image contains no devDependency and no package manager while migrate-and-start still applies all migrations on a fresh database and starts the API — or, if that could not be achieved, the protocol records it as offen with the reason (per D-09, D-10, D-23)"
- "A final run of the CI script on the fixed state (fresh clone, locally rebuilt images) is recorded as „Stand nach der Behebung“ in the protocol, every triage row carries a final status with a reason, CHANGELOG „Unveröffentlicht“ carries „Sicherheit:“ bullets for the protocol and checks, the outside check and the fixes, no module version changed, nothing is pushed and nothing is deployed (per D-12, D-13, D-21)"
artifacts:
- path: ".gitea/scripts/security-scan.sh"
provides: "install/run/all/--print-plan; pinned tools with SHA256 literals; scans of a git-archive export of HEAD, the git history, both lockfiles and the images of the ref; SECURITY-SUMMARY lines and summary.txt; always exit 0"
contains: "SECURITY-SUMMARY"
- path: ".gitleaks.toml"
provides: "default gitleaks rules plus narrow allowlists for the verified false positives"
contains: "useDefault = true"
- path: ".semgrepignore"
provides: "test files, fixtures and planning notes excluded from SAST"
- path: ".gitea/workflows/ci.yml"
provides: "job security after publish, main and v* only, continue-on-error, no secrets, artifact v3 best effort"
contains: "security-scan.sh all"
- path: ".gitea/scripts/zap-baseline.sh"
provides: "passive ZAP baseline against alpha with preflight, pinned image digest, optional Basic-Auth header from a file outside git, ZAP-SUMMARY line"
contains: "zap-baseline.py"
- path: ".gitea/scripts/zap-hooks.py"
provides: "ZAP hook: spider without form processing and POST; optional Authorization replacer rule"
contains: "set_option_post_form"
- path: "docs/sicherheitsprotokoll.md"
provides: "the living security protocol for owner and customers"
contains: "## Einordnung der Befunde"
- path: "apps/api/src/crypto/crypto.service.ts"
provides: "AES-256-GCM decrypt with enforced 16-byte authentication tag"
contains: "authTagLength"
- path: "apps/api/Dockerfile"
provides: "runtime stage from node:24-alpine with production dependencies only and without package managers"
- path: ".planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt"
provides: "small committed summary of baseline, after-allowlist, ZAP and final numbers (raw JSON stays out of git)"
key_links:
- from: ".gitea/workflows/ci.yml job security"
to: ".gitea/scripts/security-scan.sh"
via: "single run step ending in || true, after needs: publish, if main or refs/tags/v"
pattern: "security-scan\\.sh all \\|\\| true"
- from: ".gitea/scripts/security-scan.sh gitleaks step"
to: ".gitleaks.toml"
via: "--config .gitleaks.toml --redact on the full history"
pattern: "--config \\.gitleaks\\.toml"
- from: ".gitea/scripts/security-scan.sh image step"
to: "images built by publish-images.sh in the host docker daemon"
via: "GITHUB_REF → localhost:3002/schalli/tessera-ctl/{api,web}:beta or :live, trivy --image-src docker"
pattern: "localhost:3002/schalli/tessera-ctl"
- from: ".gitea/scripts/zap-baseline.sh"
to: ".gitea/scripts/zap-hooks.py"
via: "--autooff --hook mounted read-only into the pinned ZAP container"
pattern: "--hook="
- from: "docs/anleitung-betrieb.md Kapitel 9 „Eine Version freigeben“"
to: ".gitea/scripts/zap-baseline.sh and docs/sicherheitsprotokoll.md"
via: "release step: outside check against alpha, result into the protocol"
pattern: "zap-baseline\\.sh"
- from: "docs/README.md"
to: "docs/sicherheitsprotokoll.md"
via: "Markdown link in the index"
pattern: "\\]\\(sicherheitsprotokoll\\.md\\)"
- from: "apps/api/src/crypto/crypto.service.ts decrypt"
to: "node:crypto createDecipheriv"
via: "length check of the tag plus authTagLength 16"
pattern: "authTagLength"
- from: "apps/api/Dockerfile runner stage"
to: "prod-deps stage (production install with generated Prisma client)"
via: "COPY --from of node_modules and apps/api/node_modules"
pattern: "--prod"
---
<objective>
Give the owner (and later customers) one honest, plain-German record of Tessera's security work, make the security checks run automatically on every build without ever blocking it, add an outside check of alpha before each release, and fix the baseline findings that are safely fixable now.
Purpose: the user asked on 08.10. for a protocol of all security checks, CI security tooling (audit, Semgrep, Trivy, gitleaks, ZAP) and a first full baseline. The research (261009-p0m-RESEARCH.md) measured that baseline: no real secrets, but 151 known vulnerabilities in production dependencies (5 critical), one real hardening item in our own code and a 1.66 GB api image that ships development tooling.
Six tasks, executed strictly in order, each by a fresh executor, each ending in a green committed state. Task 1 is the tracer: the complete reporting chain (pinned download → scan → allowlist → summary line → report directory → CI job) proven inside the real runner image. Task 2 writes the protocol and its links. Task 3 adds the ZAP outside check and runs it against alpha for the first time. Tasks 4 and 5 fix findings and record before/after numbers in the protocol. Task 6 re-runs the CI script on the fixed state, closes the protocol and cleans up.
Locked decisions — user (08.10.2026, NON-NEGOTIABLE):
- D-01 `docs/sicherheitsprotokoll.md`: ONE document listing ALL security checks done so far (inventory: reviews, threat models, RLS/data-separation tests, security tests, WINDOWS ledger) plus the baseline full scan (all scanners, counts, triage) plus how each check works, in plain German with „Sie“ for a non-programmer owner and later customers.
- D-02 Linked from `docs/README.md` (the documentation index; there is no README at the repository root) and from the Betriebs- and the Entwicklungsanleitung.
- D-03 The protocol is kept current: the Entwicklungsanleitung gets a short „So pflegen Sie dieses Protokoll“ rule — every security review, every recorded scan and every pre-release ZAP run adds an entry.
- D-04 CI security job (gitleaks full history, pnpm audit and osv-scanner, Semgrep, Trivy fs plus Trivy on the freshly built images): REPORTING ONLY — never fails or blocks the pipeline; results visible as summary lines in the log and as artifact (best effort). Pinned, checksum-verified tool versions per research.
- D-05 OWASP ZAP baseline against alpha before releases: a script, documented as a step in Kapitel 9 „Eine Version freigeben“ of the Betriebsanleitung. Basic Auth in front of alpha stays; nothing in this task suggests removing it.
- D-06 The FIRST ZAP baseline run against alpha is executed in this task (passive baseline only, from the dev host) and recorded in the protocol.
- D-07 The two resting product topics (multi-organisation operation and licensing) are not framed as open topics anywhere; existing data-separation tests may be described as existing protection, without activation state or next stages.
Locked decisions — orchestrator (NON-NEGOTIABLE):
- D-08 (a) Dependency updates WITHIN the current majors only (Next 15.5.x latest patch; NestJS/express/multer/nodemailer/undici/axios/handlebars/adm-zip and the others patch/minor) plus pnpm `overrides` for vulnerable transitive packages where a patched version exists in range; NO major upgrades (Next 16 and Prisma 7 stay out). Re-run the audit afterwards and record before/after counts. Full test suites, local stack rebuild and e2e smoke stay green.
- D-09 (b) AES-GCM decrypt enforces a 16-byte authentication tag (`apps/api/src/crypto/crypto.service.ts`), with spec.
- D-10 (c) api production image without development dependencies — only if it does not break the Prisma migrate-and-start flow; verified by rebuilding and starting the local stack; otherwise documented as open item.
- D-11 (d) Items without a fix (xlsx 0.18.5, node-forge 1.4.0) and accepted design choices (opt-in TLS bypasses, test fixtures, gitleaks false positives) are documented in the protocol with reasoning („bewusst akzeptiert“ / „offen“), plus a gitleaks allowlist `.gitleaks.toml` for the verified false positives so future CI reports stay meaningful.
- D-12 The raw baseline JSON (6.7 MB) does not go into git; the protocol carries the summarised numbers; a small summary file stays in the quick directory.
- D-13 Docs mandatory: CHANGELOG („Unveröffentlicht“, security entries), Betriebsanleitung, Entwicklungsanleitung, README link. No module version bumps (no seed version, no module changelog entry) unless a module's user-visible behaviour changes — none of the tasks below changes one.
Planner's discretion (decided here, apply as written):
- D-14 File layout: `.gitea/scripts/security-scan.sh` (CI and local), `.gitea/scripts/zap-baseline.sh` plus `.gitea/scripts/zap-hooks.py` (local pre-release step) — same directory, POSIX sh and ASCII German comments as `publish-images.sh`. All reports go to the repository-root directory `security-reports/` (gitignored and dockerignored); the CI artifact is named `sicherheitsberichte`.
- D-15 Source scans (pnpm audit, osv-scanner, Semgrep, Trivy fs) read a `git archive HEAD` export in a temporary directory, gitleaks reads the git history only. This makes CI and local runs scan exactly the committed state: untracked or private material in a developer working tree never reaches a scanner, a report or an artifact. The script exports `GIT_CONFIG_COUNT/KEY_0/VALUE_0` with `safe.directory` for the scan root so git and gitleaks work on a checkout owned by another user (CI container as root, local clone owned by uid 1000).
- D-16 Pins (verified at planning against the official release checksum files): gitleaks 8.30.1 `https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz` SHA256 `551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb`; trivy 0.75.0 `https://github.com/aquasecurity/trivy/releases/download/v0.75.0/trivy_0.75.0_Linux-64bit.tar.gz` SHA256 `c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f`; osv-scanner 2.6.0 `https://github.com/google/osv-scanner/releases/download/v2.6.0/osv-scanner_linux_amd64` SHA256 `ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108`; semgrep 1.180.0 via `pipx install semgrep==1.180.0`; pnpm 9.15.0 via `corepack pnpm@9.15.0` (or a pnpm 9.15.x already on PATH). Tool directory: `/opt/hostedtoolcache/tessera-security` when `/opt/hostedtoolcache` is writable (CI: the persistent `act-toolcache` volume), otherwise `${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security`; `SECURITY_TOOL_DIR` overrides. Binaries live at `{tooldir}/gitleaks-8.30.1/gitleaks`, `{tooldir}/trivy-0.75.0/trivy`, `{tooldir}/osv-scanner-2.6.0/osv-scanner`; Trivy cache `{tooldir}/trivy-cache` (its `fanal` layer cache is deleted after each run, `db` stays). No marketplace actions for scanners (mutable tags, fetched from github.com).
- D-17 Summary line contract (one line per tool, ASCII, counts only, in the log and in `{reportdir}/summary.txt`): `SECURITY-SUMMARY gitleaks findings={n}`; `SECURITY-SUMMARY pnpm-audit-prod critical={n} high={n} moderate={n} low={n}` (from `metadata.vulnerabilities`); `SECURITY-SUMMARY osv-scanner packages={n}` (vulnerable package@version); `SECURITY-SUMMARY semgrep findings={n} errors={n}`; `SECURITY-SUMMARY trivy-fs critical={n} high={n} medium={n} low={n} misconfig={n} secrets={n}`; `SECURITY-SUMMARY trivy-image-api critical={n} high={n} medium={n} low={n}` and the same for `trivy-image-web`; a tool that could not run prints `SECURITY-SUMMARY {tool} skipped reason={word}`; last line `SECURITY-SUMMARY fertig (nur Bericht, Exit 0)`. `--print-plan` prints `werkzeug {name} {version}` lines, `scan-image {ref}` per image or `scan-image keine (nur main und Tags v*)`, and `berichte {dir}` — without network access.
- D-18 Job placement: `security` with `needs: publish` and an explicit `if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')` — the explicit condition is required because in Gitea a skipped `needs` does not block (that is why `publish` runs on pushes to `live`, docs/ci-cd-setup.md §4); job-level `continue-on-error: true`; checkout with `fetch-depth: 0`; one run step `sh .gitea/scripts/security-scan.sh all || true`; then `actions/upload-artifact@v3` (v4 is rejected by Gitea) with `if: always()`, `continue-on-error: true`, name `sicherheitsberichte`, path `security-reports/`, `retention-days: 30`. The job references no secret and no other job lists it in `needs`.
- D-19 Allowlist policy: the Zertifikatsmanager fixture directory `apps/api/src/cert-manager/__fixtures__/` is allowlisted as a directory (it exists to hold test keys); every other false positive is allowlisted per exact file (anchored regex `^…$` with escaped dots) together with `targetRules`, and for a file that is not a test or planning document (`apps/web/src/messages/de.json`) additionally with a `regexes` entry matching only the verified line (`regexTarget = "line"`). Every allowlist has a German `description`. Trivy skips only the fixture directory for its secret scanner. `.semgrepignore` keeps `:include .gitignore` and excludes both fixture directories, `*.spec.ts`, `*.test.ts`, `*.test.tsx` and `.planning/`.
- D-20 ZAP: pinned image `ghcr.io/zaproxy/zaproxy@sha256:7aaa659b0d43078febd82e29bad112285c370727e86ab8340444220e17d9f0d2` (2.17.0); `zap-baseline.py` with `--autooff --hook=…` (research: `-z` configuration is silently ignored in 2.17, hooks work), traditional spider only, spider minutes default 3, `-I` (warnings never fail), `-T 15`, reports `-r zap.html -w zap.md -J zap.json`; the hook switches the spider's form processing and form POSTs off; no AJAX spider (it would type into and submit the login form). Default target `https://alpha.tessera.ctl.de`, `ZAP_TARGET` overrides. Preflight `GET /login`: 200 → run without credentials (today's measured state); 401 → only with `ZAP_BASIC_AUTH_FILE` (a file outside the repository with `benutzer:passwort`), whose header reaches the container through a temporary env-file (mode 0600, removed by trap) and the hook's replacer rule — never on a command line, never in any output; otherwise stop with a German message.
- D-21 CHANGELOG: security entries are bullets with the lead-in „Sicherheit: “ inside the existing groups „Neu“, „Geändert“, „Behoben“ of „Unveröffentlicht“ — not a separate fourth group, because `apps/web/src/lib/release-notes.ts` shows only these three groups in the „Was ist neu“ window (any other group would silently disappear there) and the Entwicklungsanleitung fixes this structure; an existing bullet already uses this lead-in. This is how the orchestrator's „Sicherheit section“ is realised.
- D-22 The two packages `@nestjs-modules/mailer` and `ews-javascript-api` are removed: verified at planning that no file under `apps/api/src` imports them (only comments mention them; `mail.module.ts` says the mailer package „wird von keinem Modul mehr benutzt“, Exchange runs over raw SOAP plus httpntlm). Their trees carry handlebars (critical), liquidjs, mjml, preview-email (nodemailer 8.0.11 without an in-major fix, deepmerge-ts, uuid 9), cheerio 1.0.0 with undici 6, axios, @xmldom/xmldom, moment and uuid 8 — removal is the most conservative fix.
- D-23 Runtime images: the api Dockerfile gets a `prod-deps` stage (production-only install of `@tessera/api...` with the Prisma client generated in that stage, because the virtual-store directory name of `@prisma/client` depends on resolved peers), the runner stage starts from `node:24-alpine` instead of `base` (no corepack-prepared pnpm), and both runtime stages (api and web) remove the package managers shipped with the Node image (npm, npx, corepack, yarn) after listing what the image actually contains. If the api image cannot pass the fresh-database start and the e2e smoke within Task 5, both Dockerfiles are reverted and the item becomes „offen“ with the reason (D-10).
- D-24 Desktop lockfile: `rustls` 0.23.41 → 0.23.45 (same minor, fixes a TLS 1.3 handshake advisory) via `cargo update -p rustls --precise 0.23.45`; `glib` 0.18.5 needs 0.20 (outside the semver range, part of the GTK stack of Tauri) → „offen“. The changed Cargo.lock makes the next CI push on main rebuild the desktop packages — expected, noted in the SUMMARY.
- D-25 Evidence files (counts and status words only, never a token or credential) live in `.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/` and are committed with their task; `baseline/.gitignore` keeps every `*.json` there out of git.
- D-26 CLAUDE.md (its dated installed-stack table) is not edited by this plan; the SUMMARY tells the orchestrator which rows lag after Task 4.
Output: one script for the CI security job plus allowlists and the job itself, a ZAP script with hook, the protocol with links and maintenance rule, dependency and image fixes, the crypto hardening, evidence files and summary lines, CHANGELOG and guide updates. Commits on main, NOT pushed, nothing deployed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@./CLAUDE.md
@.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-RESEARCH.md
@.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt
Discovered facts the executor can rely on (verified during planning on 2026-10-09, HEAD d15a470):
- Working tree: besides the repository it contains untracked private material that must never reach a scanner, report, artifact, commit or evidence file, and must not be named anywhere. Scan only through the script (git-archive export plus history) or a fresh `git clone --no-local` of the repository; never point a scanner at the working tree directory.
- CI (`.gitea/workflows/ci.yml`): jobs `quality` → `test` → `desktop` (if main or v*) → `publish`; `publish` builds with `.gitea/scripts/publish-images.sh`, which tags `localhost:3002/schalli/tessera-ctl/{web,api}:{channel}` (main: `beta` plus `latest`; tags v*: `live` plus `vX.Y.Z`) in the HOST docker daemon (the runner mounts `/var/run/docker.sock`), so the images are present locally right after publish. The file header keeps a running list of quick ids — add one line for this job. `docs/ci-cd-setup.md` §4 lists four jobs in a numbered list and explains that a skipped `needs` does not block; §5 covers the docker socket; §6 is troubleshooting; that document writes umlauts as ae/oe/ue.
- Runner image `gitea/runner-images:ubuntu-latest` (present locally, digest `sha256:15f5ee61…`): node 24.16 with corepack on PATH (no pnpm), pipx, jq, python3 3.12, git, sha256sum, curl; no PyYAML. Job containers run on docker network `gitea`; `act-toolcache` volume is mounted at `/opt/hostedtoolcache`. Runner: Gitea 1.26.2 with act_runner (job summaries need Gitea 1.27 → log lines instead).
- Host: python3 3.13 (tomllib), jq, curl, pnpm 9.15.0, cargo with an existing `apps/desktop/src-tauri/target`; no pipx (semgrep is skipped on the host; that is fine). `js-yaml@4.2.0` is in `node_modules/.pnpm` (YAML parsing for the ci.yml gate). Disk: 79 % used, 38 GB free; >85 % must be reported to the user.
- Locally present research images (pruned in Task 6 by exact name, never with `-a`): `semgrep/semgrep:1.180.0`, `aquasec/trivy:0.75.0`, `ghcr.io/aquasecurity/trivy:0.75.0`, `ghcr.io/google/osv-scanner:v2.6.0`, `ghcr.io/gitleaks/gitleaks:latest`, `ghcr.io/gitleaks/gitleaks:v8.30.1` (and `curlimages/curl` if present). Keep `ghcr.io/zaproxy/zaproxy` (pinned digest above, runs as user `zap`, uid 1000) and `gitea/runner-images:ubuntu-latest`.
- Baseline gitleaks findings (20, all false positives, redacted file `baseline/gitleaks-history.json`): `private-key` in the nine `.pem` files under `apps/api/src/cert-manager/__fixtures__/`, in `apps/api/src/cert-manager/cert-keys.spec.ts` (3), `cert-output.spec.ts` (1), `cert-templates.spec.ts` (2) and `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-RESEARCH.md` (1); `generic-api-key` in `apps/web/src/messages/de.json` (an i18n label), `apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx` and `.planning/phases/12-tender-notifications/12-VALIDATION.md`; `curl-auth-user` in `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-RESEARCH.md` (an example against the local throwaway Nextcloud test container). Trivy fs secrets: 7 HIGH, all in the fixture directory. Semgrep: fixture hits, spec/test hits and `apps/api/src/tenders/__fixtures__/cosinex-search.html` are noise; real-source hits: 13 in ci.yml (12 mutable action tags, 1 curl pipe shell), `gcm-no-tag-length` in crypto.service.ts, 4 `bypass-tls-verification` (ldap.service.ts, proxmox-auth.ts, proxmox-client.service.ts, icon-discovery.service.ts — opt-in per connection), `js-open-redirect` in the login page (guarded by `sanitizeNextPath()` in `apps/web/src/lib/safe-next.ts`), 3 `detect-non-literal-regexp` (exchange providers, code constants), `prototype-pollution-loop` in `autodns-parse.ts` (read-only walk), 3 pnpm-workspace hardening keys.
- Production audit (baseline, `pnpm audit --prod`: C5 H73 M68 L5, 30 packages) and dependency chains (`pnpm why`): next 15.5.19 (direct web, fix 15.5.27; 15.5.27 also allows sharp `^0.34.3 || ^0.35.4`); proxy-addr 2.0.7 via express 5.2.1, which `@nestjs/platform-express` pins exactly (11.1.27 and 11.2.7 both pin express 5.2.1 → override); multer pinned 2.1.1 by platform-express 11.1.27 and 2.4.0 by 11.2.7; nodemailer 9.0.1 direct and via imapflow, 8.0.11/9.0.0 via `@nestjs-modules/mailer` → preview-email/mailparser; handlebars, liquidjs, mjml, cheerio 1.0.0 → undici 6.27.0, uuid 9, deepmerge-ts, brace-expansion 5.0.6 via `@nestjs-modules/mailer`; axios 1.18.1, @xmldom/xmldom 0.8.13, moment, uuid 8 via `ews-javascript-api`; underscore 1.12.1 via httpntlm; ip-address 10.2.0 via imapflow → socks; undici 7.28.0 direct (exact pin on purpose, see the „undici-Dispatcher-Falle“ in the Entwicklungsanleitung); xlsx 0.18.5 and node-forge 1.4.0 direct with no fixed version on npm. Newest in-major versions at planning (`npm view`): next 15.5.27, nodemailer 9.1.1, undici 7.30.0, @nestjs/core and platform-express 11.2.7, adm-zip 0.6.1, csv-parse 7.0.3, axios 1.20.0, handlebars 4.7.10, multer 2.4.0, proxy-addr 2.0.8, @xmldom/xmldom 0.8.15, ip-address 10.7.3, liquidjs 10.30.0, js-yaml 4.3.2, svgo 4.1.0, nanoid 3.3.20, browserslist 4.29.3, qs 6.16.0, underscore 1.13.8, linkify-it 5.0.2, moment 2.31.0, source-map-js 1.2.2, postcss 8.5.29, postcss-selector-parser 7.1.6, brace-expansion 2.1.7 and 5.0.12, sharp 0.35.5. There is no `pnpm.overrides` in the root package.json yet. Prisma is pinned 6.19.3 (CLI `prisma` is a production dependency of the api, needed by migrate-and-start).
- Image baseline (research): api:beta Node C6 H116 M98 L7 (1.66 GB; tinypool, tar 6.2.1 and pnpm 9.15.9 come from devDependencies and the corepack-prepared pnpm of the `base` stage), web:beta Node C2 H19 M21 L1 (npm's bundled packages of the Node base image: tar 7.5.19, brace-expansion 5.0.7, http-cache-semantics, glob, minimatch). Cargo.lock: rustls 0.23.41 (fix 0.23.45), glib 0.18.5 (fix 0.20.0).
- `apps/api/Dockerfile`: stages base (node:24-alpine + corepack pnpm@9) → deps (full install `--filter=@tessera/api...`) → builder (prisma generate, nest build) → runner `FROM base` copying node_modules from deps and the generated `.prisma` directory from a path that contains the resolved peer versions (`@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3`); CMD `sh apps/api/scripts/migrate-and-start.sh` (uses `apps/api/node_modules/.bin/prisma migrate deploy` and `node apps/api/dist/main.js`). `apps/api/package.json` has `postinstall: test -f prisma/schema.prisma && prisma generate || true`. Runtime needs: express is loaded through `createRequire` from the copy of `@nestjs/platform-express` (`cert-json-body.ts`); `apps/api/scripts/rls-preflight.mjs` imports `@prisma/client`; compose healthchecks use busybox `wget` (api only; the web service has none in docker-compose.prod.yml). `apps/web/Dockerfile` runner already starts from `node:24-alpine` with the standalone output. No guide tells anyone to run npm, npx or pnpm inside a container.
- Crypto: `decrypt` splits `iv:authTag:ciphertext`, builds the tag with `Buffer.from(hex)` and calls `createDecipheriv('aes-256-gcm', key, iv)` without `authTagLength`. Verified with Node 24.16: a real tag truncated to 4 bytes still decrypts (only a DEP0182 deprecation warning). Every value ever written used a 12-byte IV and the default 16-byte tag (original implementation 9ec6313, unchanged since). Both crypto files fail `biome check` today on formatting only.
- `apps/api/src/cert-manager/zip-expand.ts` reads ZIP entries in memory (`getEntries` plus its own capped inflate) and never writes entries to disk.
- CHANGELOG: „## Unveröffentlicht“ with „### Neu“, „### Geändert“, „### Behoben“; an existing „Behoben“ bullet starts with „Sicherheit: “. Rules (Entwicklungsanleitung „Änderungsliste“): plain language, „Sie“, real umlauts, no file names, no commit ids, no unexplained jargon.
- Guides: `docs/README.md` is the index (table of four guides plus paragraphs „Daneben liegt …“, „Ebenfalls dabei: …“). `docs/anleitung-betrieb.md`: intro paragraph, TOC with ten chapters, Kapitel 7 „Protokolle und Fehlersuche“ (~394), Kapitel 8 „Abgrenzung zur CI/CD-Pipeline“ (~424), Kapitel 9 with „### Eine Version freigeben“ (~511: step 1 „Änderungsliste abschließen“, then merge/tag commands; „Das Freigeben erledigt Claude“) and „### Woran Sie erkennen, welche Version läuft“ (`/health/version`). `docs/anleitung-entwicklung.md`: TOC 1–9, „## Tests“ (~781, table of Wächter-Tests), „## Konventionen und Fallstricke“ (~837, paragraphs „**Titel (quick-id):** …“).
- e2e smoke scripts (local stack, test values only, no `.env` reads): `.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh`, `.planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh`, `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all` (adm-zip, multer, node-forge, undici SSRF guard, the `build` JSON reader), Nextcloud: `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/{nc-test-setup.sh,e2e-files.sh,e2e-transfer.sh}` and `.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh all` (undici transport, Next.js proxy); the harness `e2e-lib.sh` in the mzu e2e directory provides `e2e_login`, `e2e_wait_health` and friends. Mail: `POST /auth/request-reset` with `{ "email": … }` is public and sends through the API's mail path; mailhog answers on `http://localhost:8025/api/v2/messages` (currently 0 messages). Stack: db, api :3001, web :3000, mailhog and the container `tessera-nc-test` run; `docker compose up -d --build --wait api web` rebuilds and waits.
- Git: commit only the task's files (`git add` new files, then `git commit -m "…" -- {every file of the task}`), German subject with scope `quick-261009-p0m` (for example `ci(quick-261009-p0m): …`), body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push (the user bundles pushes), never deploy, never read `.env` files. `biome check` the files you changed; where it fails only on pre-existing formatting, `biome format --write` exactly those files.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — reporting-only security job end to end: one script with pinned, checksum-verified scanners, narrow allowlists, job after publish, proven inside the real runner image</name>
<files>.gitea/scripts/security-scan.sh, .gitleaks.toml, .semgrepignore, .gitignore, .dockerignore, .gitea/workflows/ci.yml, docs/ci-cd-setup.md, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/.gitignore, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task1-runner-full.txt</files>
<read_first>.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-RESEARCH.md (sections Standard Stack, Architecture Patterns, Reporting channel, Baseline results, Common Pitfalls), .gitea/workflows/ci.yml, .gitea/scripts/publish-images.sh (header and style), docs/ci-cd-setup.md §4–§6</read_first>
<precondition>On the dev host `docker image inspect gitea/runner-images:ubuntu-latest`, `docker network inspect gitea` and `docker image inspect localhost:3002/schalli/tessera-ctl/api:beta localhost:3002/schalli/tessera-ctl/web:beta` all succeed.</precondition>
<action>
First, per D-12, create `.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/.gitignore` containing `*.json`, so the raw baseline output can never be committed; per D-14 add `security-reports/` to the root `.gitignore` and `security-reports` to `.dockerignore`.
Write `.gitea/scripts/security-scan.sh` (POSIX sh, executable, ASCII German comments) implementing D-04 and D-14 to D-18. Header comment in the style of `publish-images.sh`: quick id, purpose („nur Bericht, bricht nie ab“), modes, environment variables (`GITHUB_REF`, `SCAN_IMAGES`, `SECURITY_REPORT_DIR`, `SECURITY_TOOL_DIR`), local call examples, and that the script knows and prints no secret. Modes: `install`, `run`, `all` (install then run) and `--print-plan` (D-17, no network). Do not use `set -e`; every tool runs in its own guarded function, every non-zero tool exit is expected and swallowed, and the script ends with exit 0 in every mode. The script never writes into the current directory except the report directory (default `security-reports`, made absolute) and uses `mktemp -d` for its work directory (removed at the end).
Install (D-16): per binary tool, download with `curl -fsSL --retry 2` into `{tooldir}`, compare `sha256sum` with the literal from D-16 before extracting, keep the verified archive and verify it again on every later run before reuse, extract only the binary into `{tooldir}/{tool}-{version}/`; a failed download or a mismatch prints `SECURITY-SUMMARY {tool} skipped reason=download|checksum` and the run continues. Semgrep: `pipx install semgrep==1.180.0` with `PIPX_HOME`/`PIPX_BIN_DIR` below `{tooldir}`, reused when `semgrep --version` reports 1.180.0; no pipx → skipped. pnpm: `corepack pnpm@9.15.0` with `COREPACK_HOME` below `{tooldir}`, or a pnpm 9.15.x on PATH; neither → skipped.
Run: export the git `safe.directory` for the scan root (D-15), export HEAD with `git archive HEAD` into `{work}/src`, then: gitleaks in `git` mode over the full history of the checkout with `--config .gitleaks.toml --redact --no-banner --exit-code 0` and a JSON report; `pnpm audit --prod --json` inside the export; `osv-scanner scan source` with `--lockfile` for `pnpm-lock.yaml` and `apps/desktop/src-tauri/Cargo.lock` of the export, JSON; `semgrep scan` inside the export with `--config p/default --config p/typescript --config p/nodejs --config p/secrets --config p/dockerfile --metrics=off --json` and a per-file `--timeout` (never `--error`); `trivy fs` over the export with `--scanners vuln,misconfig,secret --exit-code 0`, skipping the Zertifikatsmanager fixture directory (D-19), cache `{tooldir}/trivy-cache`; `trivy image --image-src docker --scanners vuln,secret --exit-code 0` for each image of the plan — `refs/heads/main` → `localhost:3002/schalli/tessera-ctl/api:beta` and `…/web:beta`, `refs/tags/v*` → `…:live`, `SCAN_IMAGES` (space-separated) overrides, any other ref → none. Report files: `gitleaks.json`, `pnpm-audit-prod.json`, `osv-scanner.json`, `semgrep.json`, `trivy-fs.json`, `trivy-image-api.json`, `trivy-image-web.json` (image file names from the last path segment before the colon). Afterwards delete `{tooldir}/trivy-cache/fanal`. One inline `python3 -I` helper reads the JSON files and prints exactly the D-17 lines (a missing or unreadable report → that tool's skipped line), writes them to `{reportdir}/summary.txt`, and the final line `SECURITY-SUMMARY fertig (nur Bericht, Exit 0)`; then print where the reports are (directory, CI artifact `sicherheitsberichte`).
Write `.gitleaks.toml` per D-19: `[extend] useDefault = true`, then `[[allowlists]]` entries with German `description` (one for the fixture directory as a path; one with `targetRules = ["private-key"]` for the three cert spec files and the ikt research note; one with `targetRules = ["generic-api-key"]` for the proxmox ServerForm test and the 12-VALIDATION table; one for `de.json` with `targetRules = ["generic-api-key"]` plus a `regexes` entry matching only the verified line and `regexTarget = "line"`; one with `targetRules = ["curl-auth-user"]` for the mzu research note). All non-directory paths are anchored regexes with escaped dots. Read the finding lines from `baseline/gitleaks-history.json` (redacted) to write them; never copy a matched value anywhere. If the host run over the current history shows a finding that is not one of the 20 baseline findings, inspect it: a real secret is reported to the orchestrator and not allowlisted; a verified false positive gets the same narrow treatment.
Write `.semgrepignore` per D-19.
Edit `.gitea/workflows/ci.yml`: add one header comment line (quick-261009-p0m: Job `security` …, nur Bericht, nach publish, nie blockierend) and the job `security` named `Sicherheitspruefung (nur Bericht)` exactly as D-18 describes; leave every other job byte-for-byte unchanged.
Edit `docs/ci-cd-setup.md`: in §4 update the job count sentence to five jobs, add the list item starting with `5. **security** -- ` (after publish, main and v* only, nur Bericht, never blocks, no secrets), extend the „Ablauf:“ sentence, add a subsection `### Job security: Sicherheitspruefung (nur Bericht)` (what it scans, D-15 export, pinned tools and how to update a version: new version plus the SHA256 from the official checksum file, where the summary lines and the artifact appear, roughly five to eight minutes after publish, Trivy cache in the toolcache); add a §6 entry for a red or yellow security job (never affects images or releases; read the skipped reasons). Keep that document's ae/oe/ue spelling.
Commit these files (`ci(quick-261009-p0m): Sicherheitspruefung als reiner Bericht in der Pipeline`). Then validate against the committed state and add fix-up commits if something fails:
(1) Full run in the real runner image with a canary: `git clone -q --no-local . {tmp}/src`; inside that clone only, add `canary/notiz.txt` with a fake GitHub token generated at run time (`ghp_` followed by 36 random letters and digits from /dev/urandom) and commit it there; create a throwaway docker volume and run `gitea/runner-images:ubuntu-latest` with `--network gitea`, `-v /var/run/docker.sock:/var/run/docker.sock`, the throwaway volume at `/opt/hostedtoolcache`, the clone at `/w` as working directory and `GITHUB_REF=refs/heads/main`, executing `sh .gitea/scripts/security-scan.sh all; echo rc=$?`. Write only the `SECURITY-SUMMARY` lines and the `rc=` line to `checks/task1-runner-full.txt` (the token never goes into any file outside the throwaway clone). Expected: rc=0, `gitleaks findings=1` and trivy-fs `secrets=1` (only the canary), counted lines for every other tool including both images (this proves research assumption A1: the job container scans images of the host daemon). Remove the root-owned report directory inside the clone with a short container run, delete the clone and the throwaway volume.
(2) Host install: `sh .gitea/scripts/security-scan.sh install` on the dev host, so gitleaks, trivy and osv-scanner exist in `${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security` for later tasks (semgrep is skipped there).
(3) Append to `baseline/SUMMARY.txt` a block „nach Ausnahmelisten (2026-10-09, Task 1)“ with the numbers of the canary run minus the canary hits (inspect the canary run's JSON to subtract exactly the canary findings of gitleaks, Trivy and Semgrep) — gitleaks 0, trivy-fs secrets 0, Semgrep after `.semgrepignore`, the image counts — and the remark that raw JSON stays out of git.
Commit the evidence and summary files with the task's files (`git add -f` is not needed: `checks/` is not ignored).
</action>
<verify>
<automated>Q=.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp && TD="${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security" && node -e 'const fs=require("fs"),p=require("path");const d=fs.readdirSync("node_modules/.pnpm").find(x=>/^js-yaml@4\./.test(x));if(!d)throw new Error("js-yaml fehlt");const y=require(p.resolve("node_modules/.pnpm",d,"node_modules/js-yaml"));const j=y.load(fs.readFileSync(".gitea/workflows/ci.yml","utf8")).jobs;if(Object.keys(j).join()!=="quality,test,desktop,publish,security")throw new Error("Jobs: "+Object.keys(j));const s=j.security;if(s.needs!=="publish"||s["continue-on-error"]!==true)throw new Error("needs/continue-on-error");if(!String(s.if).includes("refs/heads/main")||!String(s.if).includes("refs/tags/v"))throw new Error("if");if(/secrets\./.test(JSON.stringify(s)))throw new Error("secrets im Job");for(const k of ["quality","test","desktop","publish"])if([].concat(j[k].needs||[]).includes("security"))throw new Error("haengt an security: "+k);const runs=s.steps.filter(x=>x.run);if(!runs.length||runs.some(x=>!/\|\| true$/.test(x.run.trim())))throw new Error("run ohne || true");if(!runs.some(x=>x.run.includes("security-scan.sh all")))throw new Error("Skript fehlt");const co=s.steps.find(x=>String(x.uses||"").startsWith("actions/checkout@"));if(!co||!co.with||co.with["fetch-depth"]!==0)throw new Error("fetch-depth");const up=s.steps.find(x=>String(x.uses||"").startsWith("actions/upload-artifact@v3"));if(!up||up["continue-on-error"]!==true)throw new Error("Artefakt");console.log("ci.yml ok")' && sh -n .gitea/scripts/security-scan.sh && GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh --print-plan | grep -qx "scan-image localhost:3002/schalli/tessera-ctl/api:beta" && GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh --print-plan | grep -qx "scan-image localhost:3002/schalli/tessera-ctl/web:beta" && GITHUB_REF=refs/tags/v9.9.9 sh .gitea/scripts/security-scan.sh --print-plan | grep -qx "scan-image localhost:3002/schalli/tessera-ctl/api:live" && GITHUB_REF=refs/heads/live sh .gitea/scripts/security-scan.sh --print-plan | grep -q "^scan-image keine" && python3 -I -c 'import tomllib,re,sys;c=tomllib.load(open(".gitleaks.toml","rb"));al=c.get("allowlists",[]);fx="apps/api/src/cert-manager/__fixtures__/";bad=[p for a in al for p in a.get("paths",[]) if fx not in p and not (a.get("targetRules") and re.search(r"\\\.[A-Za-z0-9]+\$$",p))];sys.exit(0 if c.get("extend",{}).get("useDefault") is True and al and all(a.get("description") and a.get("paths") for a in al) and not bad else 1)' && "$TD/gitleaks-8.30.1/gitleaks" git --config .gitleaks.toml --redact --no-banner --exit-code 1 . && "$TD/trivy-0.75.0/trivy" --version | grep -q "0.75.0" && "$TD/osv-scanner-2.6.0/osv-scanner" --version | grep -q "2.6.0" && git check-ignore -q "$Q/baseline/osv-scanner.json" && git check-ignore -q security-reports/probe.json && grep -q "^security-reports" .dockerignore && test -f .semgrepignore && O=$(mktemp -d) && git clone -q --no-local . "$O/src" && docker run --rm --network none -v "$O/src:/w:ro" -w /w -e GITHUB_REF=refs/heads/main gitea/runner-images:ubuntu-latest sh -c "SECURITY_REPORT_DIR=/tmp/r SECURITY_TOOL_DIR=/tmp/t sh .gitea/scripts/security-scan.sh all; echo rc=\$?" > "$O/offline.txt" 2>&1; grep -qx "rc=0" "$O/offline.txt" && test "$(grep -c "^SECURITY-SUMMARY .* skipped reason=" "$O/offline.txt")" -ge 7 && C="$Q/checks/task1-runner-full.txt" && grep -qx "rc=0" "$C" && grep -qx "SECURITY-SUMMARY gitleaks findings=1" "$C" && grep -Eq "^SECURITY-SUMMARY trivy-fs .*secrets=1( |$)" "$C" && for t in pnpm-audit-prod osv-scanner semgrep trivy-image-api trivy-image-web; do grep -Eq "^SECURITY-SUMMARY $t [a-z_]+=[0-9]+" "$C" || exit 1; done && grep -qF '5. **security**' docs/ci-cd-setup.md && grep -q "nach Ausnahmelisten" "$Q/baseline/SUMMARY.txt" && rm -rf "$O" && echo "task1 ok"</automated>
<fails_when>ci.yml does not parse or the security job is not the fifth job, lacks needs publish, the main/tag condition, job-level continue-on-error, fetch-depth 0, the run step ending in || true, or the best-effort artifact step, references a secret, or another job depends on it; the script has a syntax error or the ref plan lists wrong images (main → beta, tag → live, live branch → none); .gitleaks.toml lacks useDefault, a description, or allowlists a non-fixture path without a rule or not as a single anchored file; gitleaks over the full history still finds something; the host tool binaries are missing; raw baseline JSON or security-reports are not ignored; the offline run in the runner image exits non-zero or reports fewer than seven skipped tools; the canary run did not exit 0, did not find exactly the canary in gitleaks and Trivy, or lacks a counted line for any other tool or image; ci-cd-setup.md lacks job 5; SUMMARY.txt lacks the after-allowlist block</fails_when>
</verify>
<done>The job security exists after publish (main and v* only), never gates anything and has no secrets; one script installs pinned, checksum-verified tools, scans only the committed state and the history, prints the D-17 lines and always exits 0; narrow allowlists bring gitleaks to 0 on the real history while a planted fake token is found; proven in gitea/runner-images:ubuntu-latest including the image scans through the host daemon and an offline run; tools installed on the host; docs/ci-cd-setup.md describes the job; baseline JSON ignored; committed on main, not pushed.</done>
</task>
<task type="auto">
<name>Task 2: docs/sicherheitsprotokoll.md — inventory of all security work, first full scan with triage, how the checks work; links from README, Betriebs- and Entwicklungsanleitung; maintenance rule; CHANGELOG</name>
<files>docs/sicherheitsprotokoll.md, docs/README.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, CHANGELOG.md, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/link-check.py</files>
<read_first>.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-RESEARCH.md (Baseline results, Dependency triage, Semgrep triage, Inventory of existing security work), .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt (incl. the Task 1 block), .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task1-runner-full.txt, the eight review files named in the research inventory, .planning/WINDOWS.md, docs/README.md, the intro and Kapitel 8 of docs/anleitung-betrieb.md, TOC plus „## Tests“ and „## Konventionen und Fallstricke“ of docs/anleitung-entwicklung.md, the top of CHANGELOG.md</read_first>
<precondition>Task 1 is committed: `git log --oneline -1 -- .gitea/scripts/security-scan.sh` shows a commit and `${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security/gitleaks-8.30.1/gitleaks version` prints 8.30.1.</precondition>
<action>
Write `docs/sicherheitsprotokoll.md` per D-01 and D-07 in plain German with „Sie“, real umlauts, short sentences, every technical term explained the first time (Abhängigkeit = Baustein eines Fremdherstellers, Abbild = fertig gebauter Container, Fehlalarm, Schwachstelle). Audience: a non-programmer owner and later customers. Never paste advisory texts, exploit details, secret values, internal IP addresses or credentials; name components and severity in everyday words. Use exactly these level-2 headings in this order: `## Auf einen Blick`, `## So lesen Sie dieses Protokoll`, `## Wie die Prüfungen arbeiten`, `## Bisherige Sicherheitsprüfungen`, `## Erste vollständige Prüfung am 9. Oktober 2026`, `## Einordnung der Befunde`, `## Verlauf`.
- Title and lead: what the protocol is, for whom, „Stand: 9. Oktober 2026“, and that every new check adds an entry under „Verlauf“ (newest first).
- „Auf einen Blick“: four to six plain statements (secrets in the source: no real ones found; external building blocks: numbers and that most have a fix within the current version line; own source: one real hardening point, otherwise false alarms or deliberate choices; automatic check on every build since this date; reviews so far). Later tasks update this section.
- „So lesen Sie dieses Protokoll“: severity words Kritisch / Hoch / Mittel / Niedrig with plain meaning; status words „behoben“ (with date), „bewusst akzeptiert“ (with reason why the risk does not apply or is tolerable), „offen“ (known, not yet fixed, with reason and assessment); „Fehlalarm“ as an assessment.
- „Wie die Prüfungen arbeiten“: one short subsection per check — the automatic check on every build (runs after the images are built, only reports, never stops a build, where the numbers appear), secrets in the source and its whole history (gitleaks), external building blocks (pnpm audit, osv-scanner, Trivy on the lockfiles), own source code (Semgrep), finished images (Trivy), and the human/AI side: code reviews by Claude after larger changes, threat models in every plan, automatic tests for the data separation per organisation at database level and other security tests. Task 3 adds the outside check.
- „Bisherige Sicherheitsprüfungen“: a table with columns Datum | Prüfung | Umfang | Ergebnis | Stand | Nachweis for the eight reviews of the research inventory (Nachweis names the internal reference: `Phase 07`, `Phase 08`, `Phase 16`, `Phase 18`, `quick-261008-dts`, `quick-261008-mzu`, `quick-261009-dkv`, `quick-261009-ikt`). Verify the rows the research left open by reading the files and the git log: the fix record of Phase 08 (search the history for fixes of its review items; if none exist, state that honestly with the open items), the remaining rows of the dts fix table, the info rows of 261009-dkv. Below the table: threat models (count PLAN files with a threat model and distinct threat ids anew with grep at execution time), the data-separation work (database role without superuser rights, row-level protection on every table that carries an organisation, its guard tests) described as existing, tested protection without activation state or stages (D-07), the other security quick tasks of the research list, the WINDOWS ledger (security-relevant entries with their state; ledger entries that belong to the resting multi-organisation topic are not listed as open), the security-flavoured automated tests and e2e scripts, and the plain fact that no separate after-the-fact acceptance of planned safeguards per build stage exists so far (safeguards were checked in reviews and tests).
- „Erste vollständige Prüfung am 9. Oktober 2026“: what was scanned (state of the source, full history, both lockfiles, the beta images), the tool versions, and a table per scanner with the raw numbers (from `baseline/SUMMARY.txt`) and the numbers after the ignore lists (Task 1 block), each with one plain sentence of meaning. Explain the Semgrep scan errors plainly (some files could not be read completely or took too long; they are reported, not hidden).
- „Einordnung der Befunde“: one table with columns Befund | Wo | Bewertung | Status | Begründung. Rows (status at this point): Next.js web framework 15.5.19 — Kritisch — offen (fix within version 15 exists); express/proxy-addr, nodemailer, undici, multer, axios, handlebars, adm-zip and the other transitive packages of the research triage — offen with their severity (fixes within the version line exist or the package is unused); xlsx 0.18.5 — offen (no fixed version on npm; read only from imports chosen by logged-in users or from public open-data sources; needs replacing); node-forge 1.4.0 — offen (no fixed version; used by the Zertifikatsmanager for files that logged-in users with module access upload); the adm-zip advisory without a fix — bewusst akzeptiert, not affected (Tessera reads ZIP entries only in memory, with its own limits, and never writes them to disk); development tooling inside the api image (tinypool, tar, pnpm) — offen; packages of the Node base image (npm) — offen; desktop: rustls — offen (patch available), glib — offen (needs a newer GTK stack of the desktop framework); AES-GCM authentication tag length — Niedrig, offen (an attacker would need write access to the database); four opt-in switches that skip certificate checks (directory service, Proxmox, symbol discovery) — bewusst akzeptiert (only when an administrator explicitly allows it for one connection, documented decisions); test keys and test values in fixtures, specs and planning notes — Fehlalarm, bewusst akzeptiert (allowlisted); the i18n label, the validation table row and the example call against a local throwaway test server — Fehlalarm, bewusst akzeptiert (describe the last one as a test access of a disposable local test server, no real secret, with no further advice); open-redirect, non-literal regexp and prototype-pollution hints — Fehlalarm with the reason from the research; pipeline hygiene (unpinned action tags, rustup install via curl | sh) — Niedrig, offen; pnpm workspace hardening settings — Niedrig, offen (state which pnpm version introduces them only if you verified it in the pnpm documentation or changelog; the project uses pnpm 9.15); missing HEALTHCHECK in both Dockerfiles — Niedrig, bewusst akzeptiert for the api (Compose checks its health), offen for the web service (no health check in Compose). Every „offen“ and „bewusst akzeptiert“ row has a non-empty reason.
- „Verlauf“: entry `### 2026-10-09 — Erste vollständige Prüfung und automatische Prüfung eingerichtet` (what happened, numbers in one or two sentences, where the details are).
Create `.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/link-check.py` (stdlib only, run as `python3 -I {path}` from the repository root): for `docs/sicherheitsprotokoll.md`, `docs/README.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md` and `docs/ci-cd-setup.md` it checks every relative Markdown link that lives in the protocol, or points to `sicherheitsprotokoll.md`, or is the anchor `#sicherheitsprüfungen`: the target file exists and an anchor matches a heading of the target under GitHub's slug rule (lower case, characters other than word characters, hyphen and space removed, spaces to hyphens); prints the broken ones and exits 1 if any.
Links (D-02): `docs/README.md` gets a paragraph „Ebenfalls dabei: [Sicherheitsprotokoll](sicherheitsprotokoll.md) …“ (for owner and customers, what it contains, kept current). `docs/anleitung-betrieb.md`: one sentence with the link in the intro and a short paragraph in Kapitel 8 about the automatic security check (after publish, report only, never blocks, numbers in the run log, details in the CI runbook and the protocol).
Maintenance rule (D-03): `docs/anleitung-entwicklung.md` gets a new section `## Sicherheitsprüfungen` between „## Tests“ and „## Konventionen und Fallstricke“ (TOC entry 9 with the anchor `#sicherheitsprüfungen`, Konventionen becomes 10) with `### So pflegen Sie dieses Protokoll` (every security review, every scan worth recording — first scan, after fixing findings, when the pipeline's numbers change noticeably — and every ZAP run before a release adds a „Verlauf“ entry with date, what was checked, numbers, what was fixed or accepted; „Auf einen Blick“ and „Einordnung der Befunde“ are updated in the same change; every „offen“ or „bewusst akzeptiert“ needs a reason; the protocol never contains secret values or attack details), `### Die Prüfung in der Pipeline` (job security, D-15 to D-18 in plain words, how to update a tool version with its SHA256), `### Prüfungen von Hand wiederholen` (`sh .gitea/scripts/security-scan.sh all` from the repository root; it scans only the committed state; `SCAN_IMAGES` for local images; reports in `security-reports/`), `### Ausnahmelisten` (the D-19 rules: only verified false positives, narrowest form, German description, never whole directories outside the fixture folders).
CHANGELOG (D-13, D-21): under „## Unveröffentlicht“ → „### Neu“ one bullet starting with „Sicherheit: “ that names the Sicherheitsprotokoll (all checks so far plus the first full check, kept current) and the new automatic check on every build that only reports and never stops a build — no file names, no tool jargon without explanation.
Run `python3 -I` on the link checker, gitleaks `dir` on the protocol, and commit (`docs(quick-261009-p0m): Sicherheitsprotokoll mit Bestandsaufnahme und erster Vollpruefung`).
</action>
<verify>
<automated>P=docs/sicherheitsprotokoll.md && TD="${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security" && test -f "$P" && for h in "## Auf einen Blick" "## So lesen Sie dieses Protokoll" "## Wie die Prüfungen arbeiten" "## Bisherige Sicherheitsprüfungen" "## Erste vollständige Prüfung am 9. Oktober 2026" "## Einordnung der Befunde" "## Verlauf"; do grep -qxF "$h" "$P" || { echo "fehlt: $h"; exit 1; }; done && for k in "Phase 07" "Phase 08" "Phase 16" "Phase 18" "261008-dts" "261008-mzu" "261009-dkv" "261009-ikt" "WINDOWS" "gitleaks" "pnpm audit" "osv-scanner" "Semgrep" "Trivy" "xlsx" "node-forge" "bewusst akzeptiert" "offen" "behoben" "Fehlalarm"; do grep -qF "$k" "$P" || { echo "fehlt: $k"; exit 1; }; done && python3 -I -c 'import re,sys;t=open("docs/sicherheitsprotokoll.md",encoding="utf-8").read();low=t.lower();du=re.search(r"\b(du|dein|deine|deinen|deinem|deiner|dich)\b",t,re.I);ten=[l for l in low.splitlines() if "mandant" in l and re.search(r"offen|geplant|später|etappe",l)];sys.exit(1 if du or ten or "lizenz" in low or " Sie " not in t else 0)' && "$TD/gitleaks-8.30.1/gitleaks" dir "$P" --no-banner --redact --exit-code 1 && grep -qF "](sicherheitsprotokoll.md)" docs/README.md && grep -qF "sicherheitsprotokoll.md" docs/anleitung-betrieb.md && grep -qF "sicherheitsprotokoll.md" docs/anleitung-entwicklung.md && grep -qxF "## Sicherheitsprüfungen" docs/anleitung-entwicklung.md && grep -qxF "### So pflegen Sie dieses Protokoll" docs/anleitung-entwicklung.md && grep -qF "(#sicherheitsprüfungen)" docs/anleitung-entwicklung.md && python3 -I -c 'import sys;t=open("CHANGELOG.md",encoding="utf-8").read().split("\n");i=t.index("## Unveröffentlicht");j=next(k for k in range(i+1,len(t)) if t[k].startswith("## "));sec=t[i+1:j];hs={l[4:].strip() for l in sec if l.startswith("### ")};sys.exit(0 if hs.issubset({"Neu","Geändert","Behoben"}) and any(l.startswith("- Sicherheit: ") and "Sicherheitsprotokoll" in l for l in sec) else 1)' && python3 -I .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/link-check.py && echo "task2 ok"</automated>
<fails_when>the protocol is missing, lacks one of the seven headings, one of the eight review references, the ledger, a scanner name, the xlsx or node-forge rows or the status words; it addresses the reader informally, mentions licensing, or frames multi-organisation operation as open or planned; gitleaks finds a secret pattern in it; README, Betriebs- or Entwicklungsanleitung do not link it; the Entwicklungsanleitung lacks the section, the maintenance subsection or the TOC anchor; the CHANGELOG lacks the „Sicherheit:“ bullet naming the protocol or gains a fourth group heading; the link checker reports a broken link or anchor</fails_when>
</verify>
<done>docs/sicherheitsprotokoll.md exists with inventory, baseline, explanations and a complete triage table; README, Betriebs- and Entwicklungsanleitung link it; the Entwicklungsanleitung tells how to keep it current; CHANGELOG carries the security bullet; links checked; committed on main, not pushed.</done>
</task>
<task type="auto">
<name>Task 3: ZAP baseline script for alpha with passive-only hook, local proof (no form POST, Basic-Auth fallback), first run against alpha, protocol entry and release step</name>
<files>.gitea/scripts/zap-baseline.sh, .gitea/scripts/zap-hooks.py, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/zap-testserver.py, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task3-zap-local.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/zap-alpha-2026-10-09.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/zap-hook-basic-auth.py, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt, docs/sicherheitsprotokoll.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
<read_first>.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-RESEARCH.md (section ZAP baseline against alpha), .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/zap-hook-basic-auth.py, docs/anleitung-betrieb.md Kapitel 9 („### Eine Version freigeben“ and „### Woran Sie erkennen, welche Version läuft“), docs/sicherheitsprotokoll.md</read_first>
<precondition>Task 2 is committed (docs/sicherheitsprotokoll.md exists in HEAD) and `curl -s -o /dev/null -w '%{http_code}' https://alpha.tessera.ctl.de/login` prints 200 on the dev host; if it prints 401, stop and report — this task never asks for, stores or removes alpha's Basic Auth.</precondition>
<action>
Write `.gitea/scripts/zap-baseline.sh` (POSIX sh, executable, ASCII German header like the other scripts) per D-05 and D-20: purpose (Grundprüfung von außen vor einer Freigabe, nur passiv, keine Angriffe, keine Formulare), call forms, environment variables `ZAP_TARGET` (default `https://alpha.tessera.ctl.de`), `ZAP_SPIDER_MINUTES` (default 3), `ZAP_REPORT_DIR` (default `security-reports/zap-{UTC timestamp}`), `ZAP_DOCKER_NETWORK` (optional, passed as `--network`), `ZAP_BASIC_AUTH_FILE` (optional, see D-20). `--print-plan` prints target, image reference with digest, report directory and the full `zap-baseline.py` argument line, without any network access, and exits 0. Normal run: preflight `curl -sS -o /dev/null -w '%{http_code}' --max-time 20 {target}/login`; on 200 run without credentials; on 401 continue only when `ZAP_BASIC_AUTH_FILE` is set (repeat the preflight with the credentials passed to curl through a temporary config file, mode 0600; build `Basic {base64}` into a temporary env-file, mode 0600, removed by a trap; never echo either), otherwise stop with a German message that the target asks for a login and how to provide the file — nothing that suggests taking Basic Auth away; on any other status stop with the status. Create the report directory, make it writable for the container user `zap` (uid 1000), run the pinned image with the report directory at `/zap/wrk`, `.gitea/scripts/zap-hooks.py` mounted read-only (for example at `/zap/hooks/zap-hooks.py`), the optional env-file and network, and `zap-baseline.py -t {target} -m {minutes} -I -T 15 --autooff --hook={hook path} -r zap.html -w zap.md -J zap.json` — traditional spider only, no AJAX spider option. Afterwards read `zap.json` with `python3 -I` and print `ZAP-SUMMARY ziel={host} hoch={n} mittel={n} niedrig={n} info={n}` (risk codes 3/2/1/0, counted per alert type) followed by one line per alert type (risk word and name); exit 0 when the JSON report exists, 3 otherwise (the release step must notice a missing report).
Write `.gitea/scripts/zap-hooks.py` (stdlib only): `zap_started(zap, target)` switches the spider's form processing and form POSTs off (`zap.spider.set_option_process_form`, `zap.spider.set_option_post_form`), and only when the environment variable `ZAP_BASIC_AUTH` is present adds the replacer rule for the request header Authorization exactly as the research hook does. Delete `baseline/zap-hook-basic-auth.py` (superseded; it was never committed).
Local proof before touching alpha: write `checks/zap-testserver.py` (stdlib `http.server`, run with `python3 -I`; options port, log file, require-auth): `/` links to `/login` and `/info`, `/login` contains a POST form with user, password and a submit button, every request is logged as method, path and whether an Authorization header was present; with require-auth every request without the header gets 401. Run it on the host (bind 0.0.0.0) and the script against it with `ZAP_SPIDER_MINUTES=1` (target via the docker bridge gateway, or `ZAP_DOCKER_NETWORK=host` with 127.0.0.1 if the bridge cannot reach the host): (1) without auth → write `lauf-ohne-anmeldung POST={n} GET={n}`; (2) with require-auth and a scratch credentials file containing a dummy test value → write `lauf-mit-anmeldung POST={n} auth_ja={n} auth_nein={n}` (the single unauthenticated request is the script's own first preflight). Both lines go to `checks/task3-zap-local.txt`; expected POST=0 in both, at least two GETs, at least two authorised requests and at most one without the header. Stop the server, delete the scratch files.
First run against alpha (D-06): note alpha's version first (`curl -s https://alpha.tessera.ctl.de/api-proxy/health/version`; if that path does not answer, write „Version nicht abgefragt“), then `sh .gitea/scripts/zap-baseline.sh`. Write `checks/zap-alpha-2026-10-09.txt` with date and time, the version line, the ZAP-SUMMARY line and the alert lines only (raw reports stay in the gitignored report directory). Append a ZAP line to `baseline/SUMMARY.txt`.
Protocol: add the subsection `### Prüfung von außen vor einer Freigabe (OWASP ZAP)` under „Wie die Prüfungen arbeiten“ (it looks at alpha like a visitor without an account: start and login page, headers, cookies, delivered files; passive only, submits no forms, attacks nothing; run before every release; Basic Auth in front of alpha stays as it is and the check runs from the internal dev host); add the section `## Prüfung von außen gegen alpha` before „## Verlauf“ with a table of runs (Datum | Version | Hoch | Mittel | Niedrig | Info) and a plain explanation of each alert type of the first run; add a row per alert type of risk Niedrig or higher (informational ones in one row) to „Einordnung der Befunde“ with status „offen“ plus assessment, or „bewusst akzeptiert“ plus reason — this task changes no Tessera code and no proxy configuration; add a ZAP row to the baseline table, update „Auf einen Blick“, add a „Verlauf“ entry for the first ZAP run.
Betriebsanleitung (D-05): in Kapitel 9 „### Eine Version freigeben“ add the step „Prüfung von außen“ before merging and tagging: Claude runs `sh .gitea/scripts/zap-baseline.sh` from the dev host once alpha runs the beta state that is to be released (check the version line), records the result in the protocol („Verlauf“ entry, new findings into „Einordnung der Befunde“), and a new finding of risk „Hoch“ is fixed before the release or justified in the protocol; Basic Auth stays.
Entwicklungsanleitung: add `### Prüfung von außen (ZAP)` to „## Sicherheitsprüfungen“ (command, environment variables, outputs, why no AJAX spider and no form submission, the Basic-Auth file route for the case that alpha ever asks this host for a login).
CHANGELOG: extend the Task-2 „Sicherheit:“ bullet (or add one under „Neu“) so it says that alpha is checked from the outside before every release, passively and without attacks („von außen“ in the text).
Run the link checker, commit (`ci(quick-261009-p0m): ZAP-Grundpruefung gegen alpha vor Freigaben, erster Lauf`).
</action>
<verify>
<automated>Q=.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp && sh -n .gitea/scripts/zap-baseline.sh && python3 -I -c 'import ast;ast.parse(open(".gitea/scripts/zap-hooks.py").read())' && PL="$(sh .gitea/scripts/zap-baseline.sh --print-plan)" && printf "%s\n" "$PL" | grep -qF "ghcr.io/zaproxy/zaproxy@sha256:7aaa659b0d43078febd82e29bad112285c370727e86ab8340444220e17d9f0d2" && printf "%s\n" "$PL" | grep -qF "https://alpha.tessera.ctl.de" && printf "%s\n" "$PL" | grep -qF "zap-baseline.py" && printf "%s\n" "$PL" | grep -qF -- "--autooff" && printf "%s\n" "$PL" | grep -qF -- "--hook=" && ! printf "%s\n" "$PL" | grep -Eq "(^| )-j( |$)" && grep -qF "set_option_process_form" .gitea/scripts/zap-hooks.py && grep -qF "set_option_post_form" .gitea/scripts/zap-hooks.py && L="$Q/checks/task3-zap-local.txt" && grep -Eq "^lauf-ohne-anmeldung POST=0 GET=([2-9]|[1-9][0-9]+)$" "$L" && grep -Eq "^lauf-mit-anmeldung POST=0 auth_ja=([2-9]|[1-9][0-9]+) auth_nein=[01]$" "$L" && A="$Q/checks/zap-alpha-2026-10-09.txt" && grep -Eq "^ZAP-SUMMARY ziel=alpha.tessera.ctl.de hoch=[0-9]+ mittel=[0-9]+ niedrig=[0-9]+ info=[0-9]+" "$A" && grep -qxF "## Prüfung von außen gegen alpha" docs/sicherheitsprotokoll.md && grep -qxF "### Prüfung von außen vor einer Freigabe (OWASP ZAP)" docs/sicherheitsprotokoll.md && awk "/^### Eine Version freigeben/{f=1;next}/^### /{f=0}f" docs/anleitung-betrieb.md | grep -qF "zap-baseline.sh" && grep -qxF "### Prüfung von außen (ZAP)" docs/anleitung-entwicklung.md && test ! -e "$Q/baseline/zap-hook-basic-auth.py" && grep -qi "zap" "$Q/baseline/SUMMARY.txt" && git check-ignore -q security-reports/zap-probe/zap.json && python3 -I -c 'import re,sys;fs=[".gitea/scripts/zap-baseline.sh","docs/anleitung-betrieb.md","docs/sicherheitsprotokoll.md","docs/anleitung-entwicklung.md"];bad=[f for f in fs if re.search(r"basic.?auth[^\n]*(entfern|abschalt|deaktivier|ausschalt)",open(f,encoding="utf-8").read(),re.I)];print(bad);sys.exit(1 if bad else 0)' && python3 -I -c 'import sys;t=open("CHANGELOG.md",encoding="utf-8").read().split("\n");i=t.index("## Unveröffentlicht");j=next(k for k in range(i+1,len(t)) if t[k].startswith("## "));sec=t[i+1:j];sys.exit(0 if any(l.startswith("- Sicherheit: ") and "von außen" in l for l in sec) else 1)' && python3 -I "$Q/checks/link-check.py" && echo "task3 ok"</automated>
<fails_when>a script does not parse; the plan output lacks the pinned digest, the alpha default, zap-baseline.py, --autooff or the hook, or contains the AJAX spider option; the hook does not switch off form processing and POSTs; the local proof shows a POST, fewer than two GETs, fewer than two authorised requests or more than one request without the header; the alpha evidence lacks the ZAP-SUMMARY line; the protocol lacks the ZAP subsection or the run section; Kapitel 9 „Eine Version freigeben“ does not contain the step; the Entwicklungsanleitung lacks the ZAP subsection; the old research hook still exists; SUMMARY.txt has no ZAP line; ZAP reports are not ignored; any of the four files words Basic Auth as something to take away; the CHANGELOG bullet lacks the outside check; a link or anchor is broken</fails_when>
<human-check>Optional for the user: open the HTML report of the first run in `security-reports/zap-…/zap.html` on the dev host and compare it with the protocol's plain explanation.</human-check>
</verify>
<done>A passive ZAP baseline script with a hook that never submits forms exists, proven locally (0 POST, Basic-Auth fallback through a file); the first run against alpha is recorded with counts, explanation and triage in the protocol; Kapitel 9 lists it as a release step and Basic Auth stays untouched; Entwicklungsanleitung and CHANGELOG updated; committed on main, not pushed.</done>
</task>
<task type="auto">
<name>Task 4: Dependency fixes within the current majors — direct bumps, removal of two unused packages, pnpm overrides, rustls patch; audit before/after; full gates on the rebuilt stack</name>
<files>package.json, pnpm-lock.yaml, apps/api/package.json, apps/web/package.json, apps/api/src/mail/mail.module.ts, apps/api/src/dkv/dkv-mail.service.ts, apps/api/src/calendar/providers/exchange.provider.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/desktop/src-tauri/Cargo.lock, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task4-audit.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/mail-smoke.sh, docs/sicherheitsprotokoll.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
<read_first>package.json, apps/api/package.json, apps/web/package.json, apps/api/src/mail/mail.module.ts (head comment), the Entwicklungsanleitung paragraph on the undici dispatcher trap, docs/sicherheitsprotokoll.md („Einordnung der Befunde“, „Verlauf“), .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh</read_first>
<precondition>Task 3 is committed; the local stack (db, api, web, mailhog) runs; `curl -s http://localhost:18080/status.php` contains `"installed":true` (container tessera-nc-test).</precondition>
<action>
Implements D-08 with D-22 and D-24; no major upgrade anywhere (Next stays 15, Prisma stays exactly 6.19.3, NestJS stays 11), no module seed or module changelog change (D-13).
Before any change: write `checks/mail-smoke.sh` (bash, test values only, no `.env` reads; reuse `e2e_login` from the mzu `e2e-lib.sh`): log in as the local admin, read the admin's e-mail address from `GET /auth/me`, read mailhog's `total` from `http://localhost:8025/api/v2/messages?limit=1`, call `POST /auth/request-reset` with that address, poll mailhog for up to 20 seconds until `total` grew, print `mail ok` (exit 0) or fail. Run it on the unchanged stack — it must pass now; if the local tenant does not deliver to mailhog, stop and report instead of weakening the check. Record `vorher prod critical={n} high={n} moderate={n} low={n}` and `vorher alle …` (from `pnpm audit --prod --json` and `pnpm audit --json`, field `metadata.vulnerabilities`) in `checks/task4-audit.txt`, and save the list of package names in `pnpm-lock.yaml` to the scratchpad for the new-name check.
Removals (D-22): remove `@nestjs-modules/mailer` and `ews-javascript-api` from `apps/api/package.json` with pnpm; correct only the comments that become false — the `mail.module.ts` sentence that the mailer package stays installed, and the description of the Exchange mode „ews“ in `exchange.provider.ts` (it uses raw SOAP plus httpntlm); historical design notes that merely mention the packages stay (check `dkv-mail.service.ts` and `exchange-inbox.provider.ts` and change them only if a statement is now wrong).
Direct bumps: web `next` → `^15.5.27`; api `nodemailer` → `^9.1.1`, `undici` → the newest 7.x as an exact pin without range (7.30.0 at planning; keep the exact pin, the reason is the undici dispatcher trap), `adm-zip` → `^0.6.1`, `csv-parse` → `^7.0.3`, `@nestjs/common`, `@nestjs/core`, `@nestjs/platform-express` → `^11.2.7` together (multer 2.4.0 comes with platform-express 11.2.7); if the NestJS minor bump breaks a gate that a narrower fix avoids, fall back to the 11.1.x line plus a multer override and record why.
Desktop (D-24): in `apps/desktop/src-tauri` run `cargo update -p rustls --precise 0.23.45`, then `cargo check` and `cargo clippy` (no new warnings); if the toolchain cannot complete them, revert Cargo.lock and record rustls as offen with the reason.
Overrides: run `pnpm audit --prod --json` and `pnpm audit --json` again; for every remaining advisory on a transitive package, add a `pnpm.overrides` entry in the root `package.json` only when a patched version exists in the same major as the installed version (for 0.x versions: the same minor), with a selector that lifts only the vulnerable line (form `{name}@{vulnerable range within that major}` → `^{lowest patched}` or `~{…}` for 0.x); never across a major, never for a direct dependency (bump that instead). Expected candidates: proxy-addr (express pinned by NestJS), nodemailer in the 9 line if imapflow keeps 9.0.1, ip-address, underscore, brace-expansion lines, js-yaml, svgo, nanoid 3, browserslist, qs, linkify-it, source-map-js, postcss (also next's pinned 8.4.31 — same major), postcss-selector-parser, sharp only if next's range resolves it, and dev-only packages by the same rule. After `pnpm install`, confirm each with `pnpm why {name}` that only patched versions remain. If one override breaks a gate, drop just that one and record the package as offen with the reason. Packages without an in-range fix (xlsx, node-forge, the adm-zip advisory without fix, glib, anything needing a major) stay as documented in the protocol.
New-name check (T-p0m-SC): compare the package names of the new lockfile with the saved list; every new name must be declared by one of the updated packages (`npm view {parent}@{version} dependencies optionalDependencies`); list each with its parent in the SUMMARY; a new name without such a parent → revert the bump that brought it and record it.
Record `nachher prod …` and `nachher alle …` lines in `checks/task4-audit.txt`. Update the protocol: „Verlauf“ entry „Bausteine von Fremdherstellern aktualisiert“ with the before/after numbers, statuses in „Einordnung der Befunde“ flipped to „behoben (9. Oktober 2026)“ where fixed, reasons for what stays offen, „Auf einen Blick“ numbers. Entwicklungsanleitung „## Sicherheitsprüfungen“: add `### Abhängigkeiten aktualisieren` (in-major rule, the override rule above, each override noted in the protocol, an override is removed once its parent ships the fix, new names need a known parent). Grep the guides for old version numbers of the bumped packages and update any mention. CHANGELOG under „### Behoben“: one bullet „Sicherheit: …“ in plain words (building blocks of external manufacturers updated within their version lines, two unused ones removed, how far the number of known weaknesses in delivered building blocks fell, desktop app included). Do not touch CLAUDE.md (D-26).
Run all gates (below), commit (`fix(quick-261009-p0m): Abhaengigkeiten innerhalb der Hauptversionen aktualisiert, zwei unbenutzte entfernt`).
</action>
<verify>
<automated>Q=.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp && pnpm install --frozen-lockfile && node -e 'const fs=require("fs"),path=require("path");const rq=(p)=>JSON.parse(fs.readFileSync(p,"utf8"));const web=rq("apps/web/package.json"),api=rq("apps/api/package.json"),root=rq("package.json");const ge=(a,b)=>{const x=a.split(".").map(Number),y=b.split(".").map(Number);for(const i of [0,1,2]){if(x[i]!==y[i])return x[i]>y[i]}return true};const ver=(d)=>rq(path.join(fs.realpathSync(d),"package.json")).version;const from=(base,name)=>path.dirname(require.resolve(name+"/package.json",{paths:[fs.realpathSync(base)]}));const fail=(m)=>{console.error(m);process.exit(1)};if(api.dependencies["@nestjs-modules/mailer"]||api.dependencies["ews-javascript-api"])fail("unbenutzte Pakete noch da");if(!/^\d+\.\d+\.\d+$/.test(api.dependencies.undici))fail("undici nicht exakt gepinnt");if(!root.pnpm||!root.pnpm.overrides||!Object.keys(root.pnpm.overrides).length)fail("overrides fehlen");const n=ver("apps/web/node_modules/next");if(!(n.startsWith("15.")&&ge(n,"15.5.27")))fail("next "+n);const u=ver("apps/api/node_modules/undici");if(!(u.startsWith("7.")&&ge(u,"7.29.1")))fail("undici "+u);const nm=ver("apps/api/node_modules/nodemailer");if(!(nm.startsWith("9.")&&ge(nm,"9.1.1")))fail("nodemailer "+nm);const az=ver("apps/api/node_modules/adm-zip");if(!(az.startsWith("0.6.")&&ge(az,"0.6.1")))fail("adm-zip "+az);const pe="apps/api/node_modules/@nestjs/platform-express";const mu=rq(path.join(from(pe,"multer"),"package.json")).version;if(!(mu.startsWith("2.")&&ge(mu,"2.3.0")))fail("multer "+mu);const pa=rq(path.join(from(from(pe,"express"),"proxy-addr"),"package.json")).version;if(!(pa.startsWith("2.")&&ge(pa,"2.0.8")))fail("proxy-addr "+pa);if(ver("apps/api/node_modules/prisma")!=="6.19.3")fail("prisma");if(!ver("apps/api/node_modules/@nestjs/core").startsWith("11."))fail("nest major");console.log("pakete ok")' && python3 -I -c 'import sys;t=open("pnpm-lock.yaml",encoding="utf-8").read();sys.exit(1 if ("ews-javascript-api@" in t or "@nestjs-modules/mailer@" in t or "overrides:" not in t) else 0)' && grep -A1 '^name = "rustls"$' apps/desktop/src-tauri/Cargo.lock | grep -qxF 'version = "0.23.45"' && (cd apps/desktop/src-tauri && cargo check -q && cargo clippy -q) && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && T=$(mktemp -d) && { pnpm audit --prod --json > "$T/prod.json"; true; } && python3 -I -c 'import json,sys,re;m=json.load(open(sys.argv[1]))["metadata"]["vulnerabilities"];t=open(sys.argv[2],encoding="utf-8").read();b=re.search(r"^vorher prod critical=(\d+) high=(\d+)",t,re.M);a=re.search(r"^nachher prod critical=(\d+) high=(\d+)",t,re.M);print(m);sys.exit(0 if b and a and m["critical"]==0 and int(b.group(2)) > m["high"] else 1)' "$T/prod.json" "$Q/checks/task4-audit.txt" && docker compose up -d --build --wait api web && test "$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/login)" = "200" && bash .planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh && bash .planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && E=.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e && bash "$E/nc-test-setup.sh" && bash "$E/e2e-files.sh" && bash "$E/e2e-transfer.sh" && bash .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh all && bash "$Q/checks/mail-smoke.sh" | grep -qx "mail ok" && grep -qxF "### Abhängigkeiten aktualisieren" docs/anleitung-entwicklung.md && python3 -I -c 'import sys;t=open("CHANGELOG.md",encoding="utf-8").read().split("\n");i=t.index("## Unveröffentlicht");j=next(k for k in range(i+1,len(t)) if t[k].startswith("## "));sec=t[i+1:j];sys.exit(0 if sum(1 for l in sec if l.startswith("- Sicherheit: "))>=3 else 1)' && echo "task4 ok"</automated>
<fails_when>the lockfile is not frozen-installable; a removed package is still declared or in the lockfile; undici is not an exact pin or below 7.29.1; next is below 15.5.27 or not 15.x; nodemailer, adm-zip, multer or proxy-addr are below their patched versions or left their major; Prisma is not 6.19.3 or NestJS left 11; no overrides exist; rustls is not 0.23.45 or cargo check/clippy fails; type check, lint or a test suite fails; pnpm audit --prod still reports a critical finding or not fewer high findings than recorded before; the rebuilt stack is not healthy, /login is not 200, or any e2e smoke script or the mail smoke fails; the Entwicklungsanleitung lacks the update rule; the CHANGELOG lacks the third „Sicherheit:“ bullet</fails_when>
</verify>
<done>All in-major fixes applied (direct bumps, two unused packages removed, overrides only within majors, rustls patch), no new unexplained package names, pnpm audit --prod without critical and with fewer high findings, all suites, type check, lint, cargo, rebuilt stack, e2e and mail smoke green; protocol, Entwicklungsanleitung and CHANGELOG updated; committed on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: AES-GCM decrypt enforces a 16-byte tag (spec first); api runtime image with production dependencies only and both runtime images without package managers — or documented as offen</name>
<files>apps/api/src/crypto/crypto.service.ts, apps/api/src/crypto/crypto.service.spec.ts, apps/api/Dockerfile, apps/web/Dockerfile, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/task5-image.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/fresh-db-start.sh, docs/sicherheitsprotokoll.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
<read_first>apps/api/src/crypto/crypto.service.ts, apps/api/src/crypto/crypto.service.spec.ts, apps/api/Dockerfile, apps/web/Dockerfile, apps/api/scripts/migrate-and-start.sh, docker-compose.yml (api service environment), docs/anleitung-betrieb.md Kapitel 7, docs/sicherheitsprotokoll.md</read_first>
<precondition>Task 4 is committed and the stack was rebuilt from it (`docker compose ps --status running --services` lists api and web, `bash .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/mail-smoke.sh` prints `mail ok`).</precondition>
<behavior>
- A value produced by `encrypt('geheim')` whose tag is cut to its first 8 hex characters (4 bytes) makes `decrypt` throw (today it returns „geheim“ with a deprecation warning — the test is red before the fix)
- A tag of 17 bytes (34 hex characters) makes `decrypt` throw
- A 16-byte tag with one flipped bit makes `decrypt` throw
- `decrypt(encrypt(x))` still returns x, also for an empty string and for text with umlauts; all existing key-source tests stay green
</behavior>
<action>
Part A, per D-09 (red → green, own commit): add the behaviour tests above to `crypto.service.spec.ts` and run them — the truncated-tag test must fail first. Then in `decrypt` add a constant for 16 bytes, reject a tag whose decoded length differs (English error message in the style of the existing format error) before creating the decipher, and pass `authTagLength` with that constant to `createDecipheriv` (also to `createCipheriv` for symmetry; encryption output is unchanged because 16 is Node's default). Stored values stay readable: every value ever written used 16-byte tags (context facts). Update the class comment (format, tag length enforced, quick id). `biome format --write` and `biome check` both crypto files. Commit (`fix(quick-261009-p0m): AES-GCM-Entschluesselung verlangt 16-Byte-Tag`).
Part B, per D-10 and D-23: measure the current images first (sizes from `docker image ls`, Trivy counts for `tessera-ctl-api:latest` and `tessera-ctl-web:latest` with the host trivy binary from Task 1, `--image-src docker`, cache in the host tool directory) and write `vorher api groesse={size} critical={n} high={n}` and `vorher web …` to `checks/task5-image.txt`. List the package-manager files the Node base image contains (`/usr/local/lib/node_modules`, `/usr/local/bin`, `/opt`). Then change `apps/api/Dockerfile`: new stage `prod-deps` from `base` that copies the manifests, the lockfile, the workspace file and `apps/api/prisma/`, runs `pnpm install --frozen-lockfile --prod --filter=@tessera/api...` and makes sure the Prisma client is generated there (postinstall with the schema present, or an explicit `prisma generate` through pnpm); the runner stage starts `FROM node:24-alpine`, removes exactly the listed package managers (npm, npx, corepack, yarn and their directories) right after FROM, and copies `node_modules` and `apps/api/node_modules` from `prod-deps` instead of `deps`; the long `.prisma` copy line from the builder goes away; everything else of the runner (versions stamp ARG/ENV, user, user-files, dist, prisma directory, shared sources, scripts, assets, desktop-dist, CMD) stays identical. In `apps/web/Dockerfile` the runner stage removes the same package managers right after FROM; nothing else changes. Write `checks/fresh-db-start.sh` (bash, test values only, cleanup by trap): a throwaway network and `postgres:16-alpine` container, wait for `pg_isready`, start `tessera-ctl-api:latest` on that network with a `DATABASE_URL` to it, random `TESSERA_ENCRYPTION_KEY` (64 hex characters) and `JWT_SECRET`, wait up to 120 seconds for the startup line containing „Tessera API“, require Prisma's message that migrations were successfully applied in the log, print `frische-datenbank ok`, remove everything.
Rebuild with `docker compose up -d --build --wait api web`; check: no package manager in either container, no development-only tool in the api's `/app/node_modules/.pnpm` (vitest, tinypool, turbo, biome, Nest CLI), `@prisma/client` resolves from `/app/apps/api` (rls-preflight's import), `fresh-db-start.sh` passes, the e2e smoke set and the mail smoke pass. Measure again and write `nachher …` lines plus `ergebnis umgesetzt`. If the image cannot pass these checks within this task, revert both Dockerfiles to HEAD, rebuild, write `ergebnis offen` with the reason into `checks/task5-image.txt` and add the row „Abbild ohne Entwicklungswerkzeuge“ with status „offen“ and the reason to the protocol.
Docs: protocol — „Verlauf“ entries for both parts, statuses in „Einordnung der Befunde“ (AES-GCM tag length behoben; development tooling in the api image and package managers of the Node base image behoben or offen), image numbers before/after, „Auf einen Blick“. Betriebsanleitung Kapitel 7 (only when Part B is applied): one paragraph that the containers contain no package manager any more („keine Paketverwaltung“ — no npm, pnpm or yarn), commands inside a container run directly with `node`, and the images are smaller. Entwicklungsanleitung „## Konventionen und Fallstricke“ (only when Part B is applied): paragraph „**Laufzeitabbilder ohne Entwicklungswerkzeuge (quick-261009-p0m):**“ — the prod-deps stage, the Prisma client generated there, that a runtime import of a devDependency fails only inside the container (unit tests do not catch it; move the package to `dependencies`), and that `checks/fresh-db-start.sh`-style starts against a fresh database are the proof. CHANGELOG: „### Behoben“ bullet „Sicherheit: …“ that stored access data („Zugangsdaten“) are checked more strictly when they are decrypted; when Part B is applied, „### Geändert“ bullet „Sicherheit: …“ that the delivered server images are smaller and carry no development tools. No module version change (D-13).
Commit Part B with the docs (`build(quick-261009-p0m): Laufzeitabbilder ohne Entwicklungswerkzeuge`).
</action>
<verify>
<automated>Q=.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp && pnpm --filter @tessera/api exec vitest run src/crypto && pnpm exec biome check apps/api/src/crypto/crypto.service.ts apps/api/src/crypto/crypto.service.spec.ts && grep -qF "authTagLength" apps/api/src/crypto/crypto.service.ts && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api test && docker compose up -d --build --wait api web && test "$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/login)" = "200" && I="$Q/checks/task5-image.txt" && if grep -qx "ergebnis umgesetzt" "$I"; then docker compose exec -T -w /app/apps/api api node -e 'require("@prisma/client");console.log("prisma ok")' | grep -qx "prisma ok" && docker compose exec -T web node -e 'console.log("web ok")' | grep -qx "web ok" && ! docker compose exec -T api sh -c "command -v pnpm || command -v npm || command -v npx || command -v corepack || command -v yarn" && ! docker compose exec -T web sh -c "command -v npm || command -v npx || command -v corepack || command -v yarn" && docker compose exec -T api sh -c "ls /app/node_modules/.pnpm" | grep -q "^@prisma+client@" && ! docker compose exec -T api sh -c "ls /app/node_modules/.pnpm" | grep -Eq "^(vitest@|@vitest\+|tinypool@|turbo@|@biomejs\+|@nestjs\+cli@|@nestjs\+schematics@)" && bash "$Q/checks/fresh-db-start.sh" | grep -qx "frische-datenbank ok" && python3 -I -c 'import re,sys;t=open(sys.argv[1],encoding="utf-8").read();v=re.search(r"^vorher api .*critical=(\d+) high=(\d+)",t,re.M);n=re.search(r"^nachher api .*critical=(\d+) high=(\d+)",t,re.M);sys.exit(0 if v and n and int(v.group(2)) > int(n.group(2)) and not int(n.group(1)) > int(v.group(1)) else 1)' "$I" && grep -qi "keine Paketverwaltung" docs/anleitung-betrieb.md; else grep -qx "ergebnis offen" "$I" && grep -qF "Abbild ohne Entwicklungswerkzeuge" docs/sicherheitsprotokoll.md; fi && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && bash .planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh && E=.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e && bash "$E/nc-test-setup.sh" && bash "$E/e2e-files.sh" && bash "$E/e2e-transfer.sh" && bash "$Q/checks/mail-smoke.sh" | grep -qx "mail ok" && python3 -I -c 'import sys;t=open("CHANGELOG.md",encoding="utf-8").read().split("\n");i=t.index("## Unveröffentlicht");j=next(k for k in range(i+1,len(t)) if t[k].startswith("## "));sec=t[i+1:j];sys.exit(0 if any(l.startswith("- Sicherheit: ") and "Zugangsdaten" in l for l in sec) else 1)' && python3 -I "$Q/checks/link-check.py" && echo "task5 ok"</automated>
<fails_when>a crypto spec fails (the truncated, too long or tampered tag still decrypts, or a roundtrip breaks), biome check fails on the crypto files, authTagLength is missing, the api suite or tsc fails; the rebuilt stack is not healthy or /login is not 200; with the image change applied: @prisma/client does not resolve, a package manager is still present in api or web, a development-only tool remains in the api image, the fresh-database start does not apply migrations and reach the startup line, the api image's high count did not fall or its critical count rose, or the Betriebsanleitung lacks the note; without it: the evidence lacks „ergebnis offen“ or the protocol lacks the offen row; an e2e smoke script or the mail smoke fails; the CHANGELOG lacks the „Zugangsdaten“ bullet; a link is broken</fails_when>
</verify>
<done>decrypt rejects every tag that is not exactly 16 bytes (tests red before, green after); the api runtime image holds production dependencies only and both runtime images carry no package manager, proven by a start against a fresh database and the e2e smoke — or reverted and recorded as offen with the reason; protocol, guides and CHANGELOG updated; two commits on main, not pushed.</done>
</task>
<task type="auto">
<name>Task 6: Final run of the CI script on the fixed state, protocol close-out („Stand nach der Behebung“), summary file, cleanup of research images</name>
<files>docs/sicherheitsprotokoll.md, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/final-run.txt</files>
<read_first>docs/sicherheitsprotokoll.md, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt, .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/ (all evidence files)</read_first>
<precondition>Tasks 1–5 are committed and the running api and web containers were built from HEAD (`docker compose up -d --build --wait api web` done after the last commit).</precondition>
<action>
Final run (D-04 proof on the fixed state): `git clone -q --no-local . {tmp}/src` (no canary this time), throwaway docker volume at `/opt/hostedtoolcache`, run `gitea/runner-images:ubuntu-latest` with `--network gitea`, the docker socket, the clone at `/w`, `GITHUB_REF=refs/heads/main` and `SCAN_IMAGES="tessera-ctl-api:latest tessera-ctl-web:latest"` (the locally rebuilt images contain every fix; never retag them with registry names), executing `sh .gitea/scripts/security-scan.sh all; echo rc=$?`. Write the `SECURITY-SUMMARY` lines, the `rc=` line and one line `semgrep-regel gcm-no-tag-length={n}` (count of that rule in the run's `semgrep.json`) to `checks/final-run.txt`. Expected: rc=0, gitleaks 0, trivy-fs secrets 0, pnpm-audit-prod critical 0, the GCM rule 0. Clean up the root-owned report directory with a short container run, delete the clone and the volume.
Protocol close-out: add the „Verlauf“ entry `### 2026-10-09 — Stand nach der Behebung` with a before/after table per scanner (baseline numbers versus this run, plain one-line meaning each) and the list of what remains offen in everyday words; update „Auf einen Blick“ to the final state; go through „Einordnung der Befunde“ row by row so every row has its final status and every „offen“/„bewusst akzeptiert“ a reason (D-11); make sure nothing frames the two resting product topics as open (D-07). Append a block „nach Behebung (2026-10-09, Task 6)“ to `baseline/SUMMARY.txt`.
Cleanup (disk, memory rule): remove the research-only images by exact name — `semgrep/semgrep:1.180.0`, `aquasec/trivy:0.75.0`, `ghcr.io/aquasecurity/trivy:0.75.0`, `ghcr.io/google/osv-scanner:v2.6.0`, `ghcr.io/gitleaks/gitleaks:latest`, `ghcr.io/gitleaks/gitleaks:v8.30.1` and `curlimages/curl` if present — then `docker image prune -f` (dangling only, never `-a`); keep the pinned ZAP image and the runner image; remove leftover throwaway volumes and test report folders under `security-reports/` (keep the alpha ZAP report folder). Note `df -h /` in the SUMMARY; above 85 % it goes to the user as a clear note.
Run the link checker and gitleaks on the protocol, commit (`docs(quick-261009-p0m): Sicherheitsprotokoll mit Stand nach der Behebung`).
</action>
<verify>
<automated>Q=.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp && TD="${XDG_CACHE_HOME:-$HOME/.cache}/tessera-security" && F="$Q/checks/final-run.txt" && grep -qx "rc=0" "$F" && grep -qx "SECURITY-SUMMARY gitleaks findings=0" "$F" && grep -Eq "^SECURITY-SUMMARY trivy-fs .*secrets=0( |$)" "$F" && grep -Eq "^SECURITY-SUMMARY pnpm-audit-prod critical=0 " "$F" && for t in osv-scanner semgrep trivy-image-api trivy-image-web; do grep -Eq "^SECURITY-SUMMARY $t [a-z_]+=[0-9]+" "$F" || exit 1; done && grep -qx "semgrep-regel gcm-no-tag-length=0" "$F" && grep -qF "Stand nach der Behebung" docs/sicherheitsprotokoll.md && python3 -I -c 'import sys;t=open("docs/sicherheitsprotokoll.md",encoding="utf-8").read().split("\n");i=t.index("## Einordnung der Befunde");j=next((k for k in range(i+1,len(t)) if t[k].startswith("## ")),len(t));rows=[l for l in t[i+1:j] if l.startswith("|") and not set(l.replace("|","").strip()).issubset(set("-: "))][1:];bad=[r for r in rows if not any(s in r for s in ("behoben","bewusst akzeptiert","offen")) or not r.strip().strip("|").split("|")[-1].strip()];print(len(rows),bad);sys.exit(0 if rows and not bad else 1)' && python3 -I "$Q/checks/link-check.py" && "$TD/gitleaks-8.30.1/gitleaks" dir docs/sicherheitsprotokoll.md --no-banner --redact --exit-code 1 && "$TD/gitleaks-8.30.1/gitleaks" git --config .gitleaks.toml --redact --no-banner --exit-code 1 . && for i in semgrep/semgrep:1.180.0 aquasec/trivy:0.75.0 ghcr.io/aquasecurity/trivy:0.75.0 ghcr.io/google/osv-scanner:v2.6.0 ghcr.io/gitleaks/gitleaks:latest ghcr.io/gitleaks/gitleaks:v8.30.1; do ! docker image inspect "$i" >/dev/null 2>&1 || exit 1; done && docker image inspect ghcr.io/zaproxy/zaproxy@sha256:7aaa659b0d43078febd82e29bad112285c370727e86ab8340444220e17d9f0d2 >/dev/null && docker image inspect gitea/runner-images:ubuntu-latest >/dev/null && grep -q "nach Behebung" "$Q/baseline/SUMMARY.txt" && echo "task6 ok"</automated>
<fails_when>the final run did not exit 0, finds a secret in the history or the tree, still has a critical production advisory, still reports the GCM rule, or lacks a counted line for osv-scanner, Semgrep or an image; the protocol lacks the „Stand nach der Behebung“ entry, or a triage row has no status or no reason; a link or anchor is broken; gitleaks finds a pattern in the protocol or the history; a research image is still present, or the ZAP or runner image was removed; SUMMARY.txt lacks the final block</fails_when>
</verify>
<done>The CI script proves the fixed state inside the runner image (no secrets, no critical production advisory, GCM finding gone); the protocol shows before/after and a final status with reason for every finding; summary file complete; research images removed, ZAP and runner images kept; committed on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| CI job / dev host → GitHub releases, PyPI, ghcr.io (Trivy DB), osv.dev, npm registry, semgrep.dev | downloaded tools, databases and rules are untrusted until verified |
| security job container → host docker daemon | the job sees every image and could start containers (pre-existing for every job via the socket mount) |
| scan reports / logs / artifact → Gitea readers | reports may echo matched secret material or internal paths |
| developer working tree → scanners | the working tree holds untracked private material that must never be scanned or reported |
| dev host (ZAP container) → alpha (internet-facing via NPM, Basic Auth for outside sources) | outbound HTTP against a real server with real data |
| protocol (docs) → owner and later customers | published text about weaknesses |
| dependency resolution (pnpm overrides, cargo update) → shipped images and desktop packages | changed third-party code enters production |
| database → CryptoService.decrypt | stored ciphertext is only as trustworthy as database write access |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-p0m-01 | Tampering | scanner downloads (security-scan.sh install) | high | mitigate | exact versions, SHA256 literals in the script compared before every use including cached archives (D-16); mismatch → tool skipped; no marketplace actions for scanners; Task 1 proves the offline/skip path |
| T-p0m-02 | Elevation of Privilege | security job with the host docker socket | medium | accept | the job gets no secret (gate parses the job for any secret expression), runs after publish so it cannot alter published images, runs only pinned binaries; the socket exposure itself pre-exists for every job and is documented in docs/ci-cd-setup.md §5 |
| T-p0m-03 | Information Disclosure | reports, log lines, artifact, evidence files | medium | mitigate | gitleaks `--redact`; log lines and evidence carry counts and status words only (D-17, D-25); raw JSON only in gitignored `security-reports/` and the 30-day artifact; baseline JSON ignored (D-12); the canary token exists only inside a throwaway clone |
| T-p0m-04 | Information Disclosure | private untracked material in the working tree | high | mitigate | scanners read a `git archive HEAD` export and the git history only (D-15); validation runs on fresh clones; the material is never named |
| T-p0m-05 | Repudiation / Tampering | allowlists masking real secrets | high | mitigate | narrowest allowlists (D-19) with a structural tomllib gate (only the fixture directory as a directory, everything else single anchored files plus rules); canary proves gitleaks and Trivy still detect a new token; unknown new findings are triaged, real ones reported, never allowlisted |
| T-p0m-06 | Denial of Service | CI duration and runner disk | medium | mitigate | job only on main and v*, after publish, never in any `needs`; Trivy layer cache deleted after each run; tool cache reused; research images pruned by name in Task 6, disk level reported |
| T-p0m-07 | Denial of Service / Tampering | ZAP against alpha | medium | mitigate | passive baseline only, no AJAX spider, spider without form processing and POST (proven locally: POST=0), spider minutes capped, `-T 15`, default target alpha only; the run creates no data on alpha |
| T-p0m-08 | Information Disclosure | Basic-Auth credentials for the ZAP fallback | high | mitigate | used only after a 401, read from a file outside the repository, passed through temporary 0600 files removed by trap, never on a command line or in output; Basic Auth in front of alpha stays untouched (D-05) |
| T-p0m-09 | Tampering / Spoofing | CryptoService.decrypt with a truncated GCM tag (forgery with a short tag) | medium | mitigate | tag length exactly 16 bytes checked plus `authTagLength: 16` (D-09); specs prove 4-byte, 17-byte and tampered tags are rejected |
| T-p0m-10 | Tampering / Elevation of Privilege | known vulnerabilities in dependencies (Next.js RCE/SSRF, proxy-addr, handlebars, multer, nodemailer, undici …) | high | mitigate | in-major bumps, overrides within majors, removal of the two unused packages, rustls patch; audit before/after with 0 critical in production as gate (Task 4) |
| T-p0m-11 | Denial of Service (availability) | api runtime image change breaks migrate-and-start | high | mitigate | start against a fresh database with all migrations, rebuilt stack, e2e and mail smoke; revert-and-document fallback (D-10, D-23) |
| T-p0m-12 | Information Disclosure | published protocol | medium | mitigate | no secret values, no advisory text or exploit details, no internal IP addresses or credentials; gitleaks `dir` check on the document in Tasks 2 and 6 |
| T-p0m-13 | Elevation of Privilege | package managers and development tools inside runtime containers | low | mitigate | removed from both runtime stages, gate checks their absence (Task 5) |
| T-p0m-14 | Tampering | ci.yml change breaking every push | medium | mitigate | YAML parsed and structurally checked (job order, needs, condition, no gating), other jobs unchanged; the first real CI run is a checklist item for the orchestrator/user |
| T-p0m-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new direct dependency (only version bumps of packages already in pnpm-lock.yaml and Cargo.lock, overrides on names already present, two removals); every new transitive name must be declared by an updated parent (npm view) and is listed in the SUMMARY, otherwise the bump is reverted; semgrep pinned via pipx in a throwaway container; scanner binaries SHA256-pinned; research Package Legitimacy Audit: no package added |
</threat_model>
<verification>
- Each task's `<automated>` chain passes: Task 1 proves the CI reporting chain inside gitea/runner-images:ubuntu-latest (full run with canary, offline run, ref plan) and the allowlists on the real history; Task 2 the protocol's structure, wording rules, links and CHANGELOG; Task 3 the ZAP script, the local no-POST and Basic-Auth proof, the first alpha run and the release step; Task 4 versions, audit before/after, all suites, cargo, rebuilt stack, e2e and mail smoke; Task 5 the crypto specs and the runtime images (or the documented fallback) with a fresh-database start; Task 6 the final CI-script run on the fixed state, the protocol close-out and the cleanup.
- Not verifiable before the user pushes (listed as checklist in the SUMMARY): the first real run of job security on main (job appears after publish, SECURITY-SUMMARY lines in the log, artifact sicherheitsberichte downloadable or not — research assumption A4, job colour with job-level continue-on-error — A3, toolcache reuse on the second run — A2, publish and releases unaffected, duration).
- Multi-source coverage audit:
| Source item | Covered by |
|---|---|
| GOAL: Sicherheitsprotokoll + CI-Sicherheitsprüfungen + Behebung der Baseline-Befunde | Tasks 1–6 |
| D-01 one protocol: inventory, baseline, how checks work, plain German „Sie“ | Task 2 (Task 3 ZAP part, Tasks 4–6 updates) |
| D-02 links from README, Betriebs- and Entwicklungsanleitung | Task 2 |
| D-03 maintenance rule „So pflegen Sie dieses Protokoll“ | Task 2 (applied in Tasks 3–6) |
| D-04 CI job gitleaks history, audit/osv, Semgrep, Trivy fs + images; report only; log lines + artifact; pinned, checksum-verified | Task 1 |
| D-05 ZAP script, release step in Kapitel 9, Basic Auth stays | Task 3 |
| D-06 first ZAP run against alpha, passive, from the dev host, recorded | Task 3 |
| D-07 resting product topics not framed as open | Task 2 (wording gate), Task 6 (re-check) |
| D-08 (a) in-major updates, overrides, no majors, audit before/after, gates green | Task 4 |
| D-09 (b) AES-GCM 16-byte tag with spec | Task 5 Part A |
| D-10 (c) api image without dev dependencies or documented open item | Task 5 Part B |
| D-11 (d) no-fix items and accepted choices documented, .gitleaks.toml for false positives | Task 1 (allowlist), Task 2 (triage), Task 6 (final statuses) |
| D-12 raw JSON out of git, small summary file | Task 1 (baseline/.gitignore, SUMMARY.txt), Tasks 3/6 (appended) |
| D-13 CHANGELOG, Betriebs-, Entwicklungsanleitung, README; no module bumps | Tasks 2–5 |
| D-14 – D-26 planner decisions | Tasks 1 (14–19, 25), 3 (20), 2–5 (21), 4 (22, 24, 26), 5 (23) |
| RESEARCH: pinned stack, direct binaries instead of marketplace actions | Task 1 (D-16) |
| RESEARCH: job after publish, continue-on-error, `|| true`, artifact v3, log lines instead of job summary (Gitea 1.26) | Task 1 (D-18, D-17) |
| RESEARCH: bind-mount trap, Trivy cache growth, never gating | Task 1 (binaries in the job container, fanal deleted, needs check) |
| RESEARCH: ignore files (.gitleaks.toml, .semgrepignore) | Task 1 (D-19) |
| RESEARCH: pitfalls rate limits, timing (main/v* only, skip live), noise, raw JSON size, non-technical audience | Tasks 1, 2 |
| RESEARCH: baseline numbers per scanner | Task 2 (table), Task 1 (after-allowlist numbers), Task 6 (after-fix numbers) |
| RESEARCH: dependency triage P1/P2, accept/monitor, dev-only image hygiene | Task 4 (P1/P2), Task 5 (image hygiene), Task 2 (accept/monitor rows) |
| RESEARCH: Semgrep triage (gcm true positive, TLS bypasses, false positives, CI hygiene, workspace hardening) | Task 5 (gcm), Task 2 (all rows) |
| RESEARCH: inventory (8 reviews, threat models, data-separation tests, other quick tasks, WINDOWS ledger, security tests, no SECURITY.md files) | Task 2 |
| RESEARCH: ZAP command, options, exit codes, `-z` ignored, hook fallback, uid 1000 report directory | Task 3 (D-20) |
| RESEARCH: Dockerfile HEALTHCHECK DS-0026, Cargo.lock findings | Task 2 (rows), Task 4 (rustls) |
| RESEARCH assumptions A1 (image scan via host socket) | Task 1 full run in the runner image |
| RESEARCH assumptions A2, A3, A4 (toolcache persistence, job colour, artifact) | SUMMARY checklist for the first CI run |
| RESEARCH assumption A5 (pnpm version for workspace hardening) | Task 2 (stated only if verified) |
| RESEARCH assumption A6 (reason against marketplace actions) | D-16 (direct binaries route) |
| RESEARCH assumption A7 (15.5.27 highest 15.x) | verified at planning with npm view |
| RESEARCH assumption A8 (spider submits no forms) | Task 3 hook plus local POST=0 proof |
| RESEARCH open question 1 (xlsx / node-forge) | Task 2 (offen with reasons) |
| RESEARCH open question 2 (fix now or record) | decided by the orchestrator (D-08 – D-10), Tasks 4–5 |
| RESEARCH open question 3 (weekly scheduled run) | not planned: the research marks it as not needed now; the SUMMARY names it for the orchestrator |
| Out of scope: licensing, multi-organisation operation as open topic, password-leak or rotation advice, any deploy or docker action on the test server | not planned |
</verification>
<success_criteria>
- Job security runs after publish on main and v* only, never fails or delays anything, has no secrets, uses pinned and SHA256-verified tools, scans only committed content and history, and reports counts as SECURITY-SUMMARY lines plus a best-effort artifact — proven in the real runner image.
- gitleaks over the full history reports 0 with narrow allowlists, while a planted fake token is still detected.
- docs/sicherheitsprotokoll.md documents all security work so far, the first full scan, the outside check of alpha and the state after the fixes in plain German with „Sie“; every finding has a status with a reason; README, Betriebs- and Entwicklungsanleitung link it and the Entwicklungsanleitung explains how to keep it current.
- The ZAP baseline against alpha is a scripted, passive release step in Kapitel 9 and its first run is recorded; Basic Auth stays.
- Dependencies fixed within their majors (0 critical in production per pnpm audit, fewer high), AES-GCM tag length enforced, runtime images without development tooling (or documented offen); all suites, type check, lint, cargo, rebuilt stack, e2e and mail smoke green.
- CHANGELOG „Unveröffentlicht“ carries the „Sicherheit:“ bullets; no module version changed; commits on main, nothing pushed, nothing deployed.
</success_criteria>
<output>
Create `.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-SUMMARY.md` when done (not committed by the executor). Besides the measured gate table per task, deviations and threat status it must contain: (1) the numbers per scanner — baseline, after allowlists, after fixes — and the pnpm audit before/after lines; (2) every pnpm override with its reason and every new transitive package name with its parent; (3) the ZAP result of the first alpha run (counts, alert names, alpha version); (4) whether Part B of Task 5 was applied, the image sizes before/after, or the reason it stayed offen; (5) a checklist for the first CI run after the user's next push: job security appears after publish, its colour, the SECURITY-SUMMARY lines, whether artifact sicherheitsberichte can be downloaded (A4), toolcache reuse on the second run (A2), publish and release unaffected, duration; the desktop job will rebuild the desktop packages because Cargo.lock changed; (6) for the orchestrator: CHANGELOG security entries use the „Sicherheit:“ lead-in inside the existing groups (D-21, reason), the two removed packages (D-22), CLAUDE.md's dated installed-stack table now lags for Next.js, NestJS, nodemailer, undici (D-26), the research's weekly scheduled scan run was not built; (7) disk usage after cleanup (`df -h /`), with a clear note if above 85 %.
</output>
@@ -0,0 +1,231 @@
# Quick 261009-p0m: Sicherheitsprotokoll + CI-Sicherheitspruefungen - Research
**Researched:** 2026-10-09 (HEAD `cc713b5`, clean tree)
**Domain:** Gitea Actions (act_runner) security scanning, OWASP ZAP baseline, security-audit inventory
**Confidence:** HIGH for runner facts, baseline numbers and the ZAP header mechanism (all measured this session); MEDIUM for artifact upload / cache persistence (see Assumptions)
<user_constraints>
## User Constraints (from task prompt, locked wishes of 08.10.)
1. Separate document `docs/sicherheitsprotokoll.md` (linked from `docs/README.md` and the guides): ALL security checks done so far + a baseline full scan, kept current. Audience: non-programmer owner and later customers -> plain German, "Sie".
2. Security step in `.gitea/workflows/ci.yml`: dependency audit, Semgrep, Trivy on built images, gitleaks incl. full history. REPORTING ONLY, must never fail the build.
3. OWASP ZAP baseline against alpha (`https://alpha.tessera.ctl.de`) before releases, scripted. Basic Auth in front of alpha must NOT be removed.
4. First complete run over all of Tessera = baseline in the protocol.
### Claude's Discretion: tool selection, versions, job placement, file layout.
### Deferred / out of scope: licensing topic, Mandantenfaehigkeit as open topic, any password-leak/rotation warnings, any Docker deploy on the test server (User does pull/up).
</user_constraints>
## Project Constraints (from CLAUDE.md + memory)
- Work only through GSD entry points; German, "Sie" in docs/app texts, "du" in chat; no technical choices put to the User.
- Never print secret values (gitleaks `--redact`); `gespraech-2026-11/` must never enter git or any scan output (it is untracked; all source scans below ran on a `git archive HEAD` copy, history scan reads `.git` only).
- Gitea is reached via `localhost:3002` (host) / host gateway :3002 (job containers), never via `git.vicolab.de`; NPM blocks large uploads. Push bundled, not per small change.
- Basic Auth in front of alpha stays (memory `project_alpha_basic_auth`).
## Summary
The runner is a single `gitea-runner` (act_runner v0.6.1) whose job containers are `gitea/runner-images:ubuntu-latest` (Ubuntu 24.04, python 3.12, pipx, jq, curl, docker CLI 29.5) on Docker network `gitea`, with the host `docker.sock` mounted and a persistent `act-toolcache` volume at `/opt/hostedtoolcache`. [VERIFIED: `docker inspect` of running job container GITEA-ACTIONS-TASK-1458 mounts, runner `.runner` labels `ubuntu-latest:docker://gitea/runner-images:ubuntu-latest`, `docker run gitea/runner-images:ubuntu-latest` tool probe]. Because the socket is the HOST daemon, the images built by `publish` (tags `localhost:3002/schalli/tessera-ctl/{api,web}:{beta,latest}`) are already present locally after `publish-images.sh` and can be scanned without pulling. [VERIFIED: `docker images` on host shows exactly those tags]
All four scanners were downloaded and executed from inside the real runner image on network `gitea`: gitleaks 8.30.1, trivy 0.75.0, osv-scanner 2.6.0 binaries (SHA256 checked against the release checksum files) and `pipx install semgrep==1.180.0` (27 s) all work. [VERIFIED: probe script run in `gitea/runner-images:ubuntu-latest --network gitea`] So the CI step needs no extra images and no bind mounts.
The baseline is NOT clean on dependencies: the lockfile has 151 production vulnerabilities (5 critical, 73 high), dominated by a handful of packages that have patch-level fixes (Next.js 15.5.19 -> >=15.5.27 first). Secrets are clean (20 gitleaks hits, all test fixtures/doc snippets). Source findings are almost all false positives or accepted design (one real hardening item: AES-GCM auth-tag length). ZAP needs no header trick today: the dev host and containers on the `gitea` network get HTTP 200 on `/login` of alpha (no 401); a verified fallback via ZAP hook exists.
**Primary recommendation:** Add ONE job `security` to `ci.yml` with `needs: publish`, `continue-on-error: true`, every step ending `|| true`; install pinned binaries by curl+sha256; Trivy cache in `/opt/hostedtoolcache`; reports -> log summary lines + best-effort `upload-artifact@v3`; ZAP as a local script `scripts/security/zap-baseline-alpha.sh` (not in CI, because alpha is internal-only reachable).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary | Rationale |
|---|---|---|---|
| Dependency/secret/SAST scans | CI runner (job container) | dev host (manual re-run) | scanners read repo + lockfile; no runtime service involved |
| Image scan | CI runner via host docker daemon | — | images exist only in the host daemon after `publish` |
| ZAP baseline | Dev host (docker) | — | alpha is reachable internally only; CI job containers also reach it (200) but release gate is manual |
| Protocol document | Repo `docs/` | `.planning` raw outputs | human-readable German doc; raw JSON stays out of docs |
## Standard Stack (pinned, checked 2026-10-09)
| Tool | Version | Source / form | Notes |
|---|---|---|---|
| gitleaks | 8.30.1 (2026-03-21) | GitHub release tar.gz + `gitleaks_8.30.1_checksums.txt` | `gitleaks git --redact --exit-code 0` for full history [VERIFIED: ran it] |
| trivy | 0.75.0 (2026-10-01) | GitHub release `trivy_0.75.0_Linux-64bit.tar.gz` + checksums | prefer plain binary over `trivy-action`; DB from ghcr.io worked [VERIFIED] |
| osv-scanner | 2.6.0 (2026-09-14) | release binary + `osv-scanner_SHA256SUMS` | `scan source --lockfile pnpm-lock.yaml` [VERIFIED: ran it]; sends package list to osv.dev |
| semgrep | 1.180.0 (2026-10-07) | `pipx install semgrep==1.180.0` | `--metrics=off`; rule packs download from semgrep.dev [VERIFIED] |
| pnpm audit | pnpm 9.15 | already in CI | `--prod` split; uses npm audit endpoint, worked today [VERIFIED]; could be retired by registry -> osv-scanner is the fallback |
| OWASP ZAP | `ghcr.io/zaproxy/zaproxy:stable` = 2.17.0 (digest `sha256:7aaa659b0d43...`) | docker | pin by digest in script [VERIFIED: pulled] |
Package Legitimacy Audit: no npm/PyPI/crates dependency is added to the repo (binaries from official GitHub releases with checksum verification, `semgrep` installed with pipx in the throwaway job container). `semgrep` is a well-known PyPI package; version pinned. Nothing flagged. Do NOT use `aquasecurity/trivy-action`/marketplace actions: mutable tags are exactly what Semgrep flags in our own ci.yml, and Gitea resolves them from github.com. [ASSUMED: reason; the direct-binary route is verified]
## Architecture Patterns
### CI flow
```
push main / v* tag
quality -> test -> desktop -> publish (build+push images; images stay in host daemon)
|
v
security (needs: publish, continue-on-error, never gates anything)
checkout fetch-depth:0 -> install pinned tools (curl+sha256, pipx semgrep)
-> gitleaks git (history) -> osv-scanner (pnpm-lock.yaml + Cargo.lock)
-> pnpm audit --prod -> semgrep (5 packs)
-> trivy fs (misconfig+secret) -> trivy image api+web (host docker.sock)
-> print "SECURITY-SUMMARY tool=count" lines -> upload-artifact@v3 (best effort)
```
Runner is serial (one job at a time) so the job adds ~5-8 min to the queue after `publish`; placing it after `publish` guarantees it can never delay or block publishing/releases. Run only when `gitea.ref` is main or `v*` (images exist); for other refs skip the image step.
### Skeleton (planner adapts)
```yaml
security:
name: Sicherheitspruefung (nur Bericht)
runs-on: ubuntu-latest
needs: publish
continue-on-error: true # job level; steps below also swallow errors
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # gitleaks needs full history
- name: Werkzeuge installieren
run: sh .gitea/scripts/security-scan.sh install || true
- name: Pruefungen (nur Bericht)
run: sh .gitea/scripts/security-scan.sh run || true
- uses: actions/upload-artifact@v3 # v4 is rejected on Gitea (GHES detection)
if: always()
continue-on-error: true
with: { name: security-reports, path: security-reports/ }
```
Put logic in a script `.gitea/scripts/security-scan.sh` (same pattern as `publish-images.sh`; runnable locally; no secret handling). Inside: `export TRIVY_CACHE_DIR=/opt/hostedtoolcache/trivy`, `set +e`, every tool `--exit-code 0` or `|| true`, final `exit 0`. Exit codes to neutralise: osv-scanner exits 1 on findings, pnpm audit exits 1, trivy/gitleaks/semgrep exit 0 unless `--exit-code`/`--error` set (we do not set them). [VERIFIED for osv/pnpm audit/gitleaks/trivy/semgrep via local runs: pnpm audit rc=1; others rc 0 with `--exit-code 0`]
Ignore files to add so reports stay readable: `.gitleaks.toml` allowlist paths `apps/api/src/cert-manager/__fixtures__/`, `.planning/`; `.semgrepignore` (fixtures, `*.spec.ts`/`*.test.tsx`); `.trivyignore` not needed initially.
### Anti-patterns
- `docker run -v $PWD:/src ...` inside a job container: the path is a path inside the job container, the daemon is the host's -> empty mount. Use binaries (verified) or `--volumes-from "$(hostname)"` [ASSUMED, untested].
- Letting the Trivy cache grow: the 1.4 GB seen locally was mostly `fanal/` layer cache from the two image scans. `rm -rf $TRIVY_CACHE_DIR/fanal` at the end; keep only `db/`. The runner shares a disk that has filled before (memory `project_disk_cleanup`).
- Adding the job to `needs:` of anything, or `if: failure()` hooks: keeps it non-gating.
### Reporting channel
- Gitea 1.26.2 is installed; job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea 1.27 + runner 2.0. [CITED: gitea.com/gitea/runner/pulls/917, blog.gitea.com/release-of-runner-2.0.0] -> not usable now. Use log lines (`SECURITY-SUMMARY gitleaks=20 osv=43 trivy_api=C6/H116 ...`) so counts are readable in the run log.
- `actions/upload-artifact@v4` is not supported on Gitea (detected as GHES); v3 or `christopherHX/gitea-upload-artifact@v4` work. [CITED: gitea.com/actions/gitea-upload-artifact, code.forgejo.org/forgejo/runner/issues/144] Our runner address is the internal `http://gitea:3000` (memory 24.09.); upstream notes artifact URLs can break with a non-public runner URL [CITED: same search result] -> treat artifacts as best effort, verify on first run, never rely on them. Existing `actions/cache/{save,restore}` is proven in this runner (cache host 172.18.0.1:42641) and is the fallback for passing files between jobs, not for reading them.
## Baseline results (raw files in `baseline/`, SUMMARY.txt there)
All run today against HEAD, no calls to our servers (only github/ghcr/semgrep.dev/osv.dev/npm registry). Gitleaks output redacted; Trivy/Semgrep excerpts of fixture keys redacted.
| Scan | Result |
|---|---|
| gitleaks full history (1367 commits, 24.5 MB, 6 s) | 20 hits, **0 real**: 16 `private-key` = 8 test fixtures in `apps/api/src/cert-manager/__fixtures__/` (+ 6 spec/RESEARCH snippets with `-----BEGIN` text), 3 `generic-api-key` = i18n label in `de.json:734`, test key `settings.authMethodLabel`, a table row in `12-VALIDATION.md`; 1 `curl-auth-user` = example against the local throwaway Nextcloud test container in `261008-mzu-RESEARCH.md:280` (docs only; consider masking). Gitleaks does not detect the formerly removed OWA IP (not a secret pattern). |
| pnpm audit (all) | 171 vulns / 151 advisories: C7 H85 M74 L5 (1268 deps) |
| pnpm audit --prod | 151: **C5 H73 M68 L5** across 30 packages |
| osv-scanner on pnpm-lock.yaml | 43 vulnerable package@version of 1262 |
| trivy fs | pnpm-lock = same 151 (C5 H73 M68 L5); Cargo.lock 2 MEDIUM; misconfig: DS-0026 "no HEALTHCHECK" LOW in `apps/api/Dockerfile` and `apps/web/Dockerfile`; 7 HIGH "secrets" = test key fixtures |
| trivy image api:beta | Node pkgs **C6 H116 M98 L7**, Alpine 3.24 OS pkgs M1, secrets 0 |
| trivy image web:beta | Node pkgs **C2 H19 M21 L1**, Alpine M1, secrets 0 |
| semgrep (280 rules, 1145 files, 3 min) | 58 findings (ERROR 9, WARNING 46, MEDIUM 3); 59 scan errors (43 partial parsing, 16 timeouts - use `--timeout`/accept) |
### Dependency triage (production, by package)
| Priority | Package (installed -> fix) | Path | Reachability / hint |
|---|---|---|---|
| P1 | **next 15.5.19 -> >=15.5.27** (2 crit RCE incl. image optimisation, SSRF, DoS, cache poisoning; 12 advisories) | direct `apps/web` (`^15.3.0`) | web is internet-facing behind NPM; same-major patch, low risk; also clears 2 crit + most of web image |
| P1 | proxy-addr 2.0.7 -> 2.0.8 (crit, IP spoofing) | express 5.2.1 via `@nestjs/platform-express` | only relevant if `trust proxy` is set; pnpm override |
| P1 | nodemailer 9.0.1 (+8.0.11 in a dep) -> >=9.1.1/10.0.6 (8 advisories) | direct api | mail with admin/user-influenced addresses |
| P1 | undici 7.28.0 -> 7.29.1 (21 advisories, SSRF-guarded fetch uses it) | direct api pin `7.28.0` | pin bump |
| P2 | handlebars 4.7.9 -> 4.7.10 (crit) | `@nestjs-modules/mailer` | needs attacker-controlled template; templates are admin-edited -> low reachability, still bump |
| P2 | multer 2.1.1 -> 2.3.0, axios 1.18.1 -> 1.20.0 (via `ews-javascript-api`), adm-zip 0.6.0 -> 0.6.1 (direct, cert-manager ZIP; `zip-expand.ts` has own limits per 261009-ikt review), brace-expansion, @xmldom/xmldom, ip-address, js-yaml, liquidjs, postcss, sharp, svgo, nanoid, browserslist, source-map-js, qs, uuid, moment, csv-parse | mostly transitive | mostly DoS/ReDoS; lockfile refresh / `pnpm.overrides` |
| accept/monitor | **xlsx 0.18.5** (2 high; npm has no fix, fixed 0.20.2 only on SheetJS CDN), **node-forge 1.4.0** (high, no fixed version listed), adm-zip symlink advisory (no fix) | direct api (`handelsware-xlsx.ts`, `doe-opendata.adapter.ts`; cert-manager) | decision needed: replace xlsx (exceljs/read-excel-file) or vendor CDN tarball; document as accepted risk in protocol |
| dev-only / image hygiene | tinypool (crit, vitest), tar 6.2.1 (crit), pnpm 9.15.9 (12 high) appear in the **api image** = devDependencies + corepack pnpm shipped in runtime image | `apps/api/Dockerfile` | follow-up: install prod-only (`pnpm deploy --prod`) and drop pnpm from the final stage; shrinks 1.66 GB image and cuts api C6/H116 sharply |
### Semgrep triage (non-test source)
| Rule | Where | Verdict |
|---|---|---|
| gcm-no-tag-length | `apps/api/src/crypto/crypto.service.ts:97` (`createDecipheriv('aes-256-gcm'...)`, authTag from stored value, no `authTagLength`) | **true positive (hardening, low)**: attacker needs DB write; fix = pass `{ authTagLength: 16 }` and check length |
| bypass-tls-verification (4) | `ldap.service.ts:182`, `proxmox-auth.ts:84`, `proxmox-client.service.ts:173`, `icon-discovery.service.ts:62` | accepted by design: opt-in per connection (`tlsRejectUnauthorized`), comments cite decisions D-04/T-JDD-01; document in protocol |
| js-open-redirect | `apps/web/.../login/page.tsx:36` | false positive: `sanitizeNextPath()` in `lib/safe-next.ts` rejects non-`/`, `//`, `/\` |
| detect-non-literal-regexp (3 prod) | `exchange.provider.ts:85`, `exchange-inbox.provider.ts:165,179` | false positive: `attr` is a code constant, not user input |
| prototype-pollution-loop | `autodns-parse.ts:144` | false positive (read-only path walk) |
| gha-curl-pipe-shell + 12 mutable-action-tag | `.gitea/workflows/ci.yml` (rustup install; `actions/*@v4`) | low, CI hygiene; pinning optional |
| pnpm-workspace hardening (3) | `pnpm-workspace.yaml`: `minimumReleaseAge`, `blockExoticSubdeps`, `trustPolicy` not set | useful low-cost supply-chain hardening; needs pnpm >=10.x semantics - check before adopting (pnpm is 9.15.0) [ASSUMED] |
| test/fixture hits | private-key fixtures (7), spec/test files, HTML fixture links | ignore via `.semgrepignore` |
## Inventory of existing security work (for the protocol)
[VERIFIED by reading frontmatter/fix sections of each file this session; counts = critical/warning/info]
| Date | Artefact | Scope | Findings | Fix status |
|---|---|---|---|---|
| 2026-06-27 | `phases/07-dkv-fleet-module/07-REVIEW.md` + `07-REVIEW-FIX.md` | 44 files DKV module | C3 W5 I4 (12) | 8/8 C+W fixed (`all_fixed`) |
| 2026-07-01 | `phases/08-dashboard-widgets.../08-REVIEW.md` | 24 files widgets, favorites, icon-discovery | C2 W5 I3 (10) | status `issues_found`; fix record not located -> executor must check |
| 2026-08-06 | `phases/16-ad-gruppen-synchronisation/16-REVIEW.md` | 21 files LDAP/groups | C0 W4 I2 (6) | warnings fixed 2026-08-06, info open |
| 2026-09-16 | `phases/18-desktop-client-fertigstellen/18-REVIEW.md` + `-FIX` | 23 files updater/desktop/Dockerfile | C1 W3 I2 (6) | 4 fixed, 2 info skipped, status clean |
| 2026-10-08 | `quick/261008-dts-.../261008-dts-REVIEW.md` | 37 files Domains/AutoDNS | C1 W6 I4 (11) | "Fix Status" table present (CR-01, WR-01, WR-02 fixed in `cc1c83a`); remaining rows not all read |
| 2026-10-08 | `quick/261008-mzu-.../261008-mzu-REVIEW.md` | 54 files Nextcloud-Dateien | C2 W9 I7 (18) | `all_fixed`, 12 commits |
| 2026-10-09 | `quick/261009-dkv-.../261009-dkv-REVIEW.md` | Nextcloud shares (5 commits) | C1 W4 I6 (11) | CR-01, WR-01, WR-02 fixed (`78f6cf3`, `d487a00`); info rows unverified |
| 2026-10-09 | `quick/261009-ikt-.../261009-ikt-REVIEW.md` | 62 files Cert-Manager (SSRF/ZIP) | C3 W7 I5 (15) | fixed (`bfcf6d4`, `c5bffe9`), each blocker has regression test; SSRF design in `cert-aia.ts` reproduced as sound |
| Total reviews | 8 | | **C13 W43 I33 = 89** | |
Other evidence (cite in protocol):
- **Threat models:** 178 PLAN files carry a `<threat_model>`/STRIDE section; 872 distinct `T-xxx-nn` threat IDs in PLANs. [VERIFIED: grep counts]
- **Tenant isolation (RLS), quick tasks 260909-dgj ... 260914-eym** (about 20 tasks, Etappen 1/2/3b/3c, switch OFF per memory): DB role `tessera_app` without superuser/BYPASSRLS, RLS on all 20 `tenantId` tables, `apps/api/scripts/rls-preflight.mjs` (5 proofs), guarded by `rls-app-role.spec.ts`, `rls-coverage.spec.ts`, `rls-preflight.spec.ts`, `rls-access-inventory.spec.ts` (72 file/model pairs, 4 detection forms), `auth-lookup-functions.spec.ts`. Docs: `docs/mandantentrennung-*.md`.
- **Other security quick tasks:** 260701 calendar SSRF fix; 260921-oxm IMAP STARTTLS enforced; 260921-fi3 forced password change enforced at API; 260914-ebg SUPER_ADMIN target-role guard (privilege escalation, WINDOWS #29); 260911-mkj RLS relation blind spot (WINDOWS #27); 260921-m34 288 `any` triaged; 260630-gbh password/avatar scoping; 260909-cx0 file-backup volume.
- **Ledger `.planning/WINDOWS.md`:** 39 entries, 25 fixed, 13 open, 1 waived (as of 2026-09-21); security-relevant: #18-#20 (RLS bypass by app role, extension connection), #22, #23, #29, #33. Open ones are mostly un-run browser checks; executor should list which are security-relevant.
- **Security-flavoured automated tests:** 44 spec/test files match ssrf|redos|zip|traversal|injection|xss|rls (cert-manager, nextcloud transfer, favorites `isPublicHttpUrl`, proxmox "nur lesen", user/guard specs, tender adapters). E2E scripts under `.planning/quick/{w5w,mzu,ikt,who,dkv}-*/e2e/`. Milestone audit: `.planning/v1.1-MILESTONE-AUDIT.md`. Per-quick `*-VERIFICATION.md` (~50).
- No `*-SECURITY.md` files exist (`/gsd-secure-phase` never run) -> protocol should say so plainly.
## ZAP baseline against alpha
- **Reachability (single-shot checks):** `curl -sI https://alpha.tessera.ctl.de/` from the dev host -> `HTTP/2 307 location: /login`, `/login` -> **200** (resolved to 217.7.63.32; no `WWW-Authenticate`). Same 200 from a container on the default bridge and on network `gitea`. So internal/hairpin sources are excepted from Basic Auth today; **no credentials needed, Basic Auth untouched**. [VERIFIED: curl/ docker run curlimages/curl]
- **Command (local script, run before a release):**
```bash
mkdir -p out && chmod 777 out # image runs as uid 1000 (zap); /zap/wrk is NOT in the image
docker run --rm -v "$PWD/out:/zap/wrk:rw" -t ghcr.io/zaproxy/zaproxy@sha256:7aaa659b0d43... \
zap-baseline.py -t https://alpha.tessera.ctl.de -m 5 -I -j -T 15 \
-r zap.html -w zap.md -J zap.json
```
Options [VERIFIED via `zap-baseline.py -h` and docs www.zaproxy.org/docs/docker/baseline-scan/]: `-m` spider minutes (default 1), `-I` never fail on WARN (exit codes: 0 ok, 1 FAIL, 2 WARN, 3 error; script must still `exit 0` after copying reports), `-j` extra AJAX spider (matters for Next.js SPA; adds time), `-r/-w/-J/-x` HTML/Markdown/JSON/XML. Passive only (spider + passive scan; no attacks). Measured: tiny local site with `-m 1` = 38 s; alpha with `-m 5 -j` plan for ~8-10 min. Unauthenticated: it will mostly see `/login` + assets; authenticated coverage needs a login context (optional later, would need a dedicated low-privilege test user).
- **If alpha ever stops excepting the source IP** (tested end to end against a local header-logging server): `-z "-config replacer..."` is **silently ignored** in 2.17 (automation-framework mode) - header arrived as `None`. What works: `--autooff --hook=/zap/wrk/hook.py` with `zap.replacer.add_rule(... matchtype='REQ_HEADER', matchstring='Authorization', replacement=os.environ['ZAP_BASIC_AUTH'])`, passing `-e ZAP_BASIC_AUTH='Basic <b64>'` from a file outside git; server log showed `auth=YES` on every request. Hook saved as `baseline/zap-hook-basic-auth.py`. [VERIFIED]
- Do not run it from the `gitea` runner by default: needs `/zap/wrk` writable volume (bind-mount pitfall above) and 3.8 GB image pull. Keep it a manual pre-release step; protocol lists date + counts per run. Provide a 'ZAP' line in `docs/anleitung-betrieb.md` chapter 9 (release steps).
## Don't Hand-Roll
| Problem | Use | Why |
|---|---|---|
| secret detection in history | gitleaks | entropy + rules, redaction |
| lockfile CVE matching | osv-scanner / trivy / pnpm audit | advisory DBs |
| SAST | semgrep registry packs | maintained rules |
| image CVEs | trivy image | OS + language layers |
| report assembly | a ~40-line script reading the JSONs with jq/python | do NOT build a dashboard |
## Common Pitfalls
1. **Bind-mount path trap** (above) - verify first run with `ls` of what the scanner sees.
2. **Rate limits on first run:** Trivy DB (ghcr.io) and OSV/npm; cache DB in toolcache; failures must not fail the job (they only drop that report).
3. **Timing:** every push already takes 6-13 min and Gitea runs jobs serially; the Tag push triggers 3 runs -> 3 security runs. Consider `if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref,'refs/tags/v')` and skip on branch `live` (same commit as the tag).
4. **Noise destroys the protocol:** commit the allowlists first; baseline numbers in the protocol must say "after ignoring test fixtures".
5. **Raw JSON size:** `baseline/` is 6.7 MB; commit only `SUMMARY.txt` + (optionally) compact CSV/MD digests, keep big JSON out of git (or gitignore) - planner decides.
6. Protocol audience is non-technical: translate (Kritisch = "muss zeitnah behoben werden"), explain "Testschluessel = harmlos", never paste advisory text.
## Environment Availability
| Dependency | Needed by | Available | Version | Fallback |
|---|---|---|---|---|
| docker (host) | local baseline, ZAP | yes | 29.8.0 | — |
| Runner job image tools (python3, pipx, jq, curl, docker.sock) | CI step | yes | py 3.12.3, docker CLI 29.5.2 | — |
| github.com / ghcr.io / semgrep.dev / osv.dev from job network | downloads | yes (probe ok) | — | mirror binaries in toolcache |
| pnpm audit endpoint | pnpm audit | yes | — | osv-scanner |
| alpha reachable from dev host + `gitea` net | ZAP | yes (200) | — | ZAP hook with Basic Auth header |
| Disk | caches/images | 45 GB free (74 % used) | — | clear `fanal` cache; ZAP image 3.8 GB, semgrep 1.6 GB pulled locally |
Local images pulled for this research (can be pruned, `docker image prune -f`, never `-a`): trivy, osv-scanner, semgrep, gitleaks, zaproxy, curlimages/curl.
## Assumptions Log
| # | Claim | Risk if wrong |
|---|---|---|
| A1 | Job container reaches the HOST docker daemon through the mounted socket, so `trivy image localhost:3002/...:beta` finds the just-built tags (mount verified; trivy-in-job-container not run) | image scan step needs `docker save`/`pull` fallback |
| A2 | `act-toolcache` volume persists across jobs so Trivy DB cache survives | DB re-downloaded each run (~1 min), harmless |
| A3 | Job-level `continue-on-error: true` keeps the run green in Gitea 1.26 (step-level `|| true` makes it moot) | red job icon only, nothing gates on it |
| A4 | `upload-artifact@v3` works with runner address `http://gitea:3000` | no downloadable reports; log lines remain |
| A5 | pnpm-workspace hardening keys (`minimumReleaseAge` etc.) require pnpm >=10 | adoption must be tested |
| A6 | Reason to avoid marketplace actions (mutable tags / supply chain) | none, binaries route is verified anyway |
| A7 | Next 15.5.27 is the highest 15.x fix; verify with `npm view next@15 version` before pinning | wrong target version |
| A8 | ZAP spider does not submit forms in baseline mode (passive) | would create data on alpha; check report |
## Open Questions
1. **xlsx / node-forge no-fix:** replace xlsx (needs behaviour tests for DATEV/DOE imports) or accept? Recommend: protocol entry "accepted until replaced", follow-up quick task.
2. **Fix the P1 dependency list now or only record?** The wish is reporting only for CI; remediation is a separate quick task (Next bump first). Planner should add "Befund-Abarbeitung" as follow-up, not inside this task.
3. Should `docs/sicherheitsprotokoll.md` include a "Wiederholung" cadence (weekly `schedule:` workflow run for new CVEs)? Recommend a weekly cron in a second tiny workflow later; not needed for v1.
## Validation Architecture
Framework: shell/CI-level only. Checks: (a) `sh .gitea/scripts/security-scan.sh run` locally exits 0 even with findings and writes `security-reports/`; (b) `GITHUB_REF=refs/heads/feature sh ... run` skips image scans; (c) YAML lint via existing push; (d) first CI run: job green, `SECURITY-SUMMARY` lines visible, publish unaffected (job is after it). Doc check: German "Sie", no secret values, links from `docs/README.md` and guides resolve. Wave 0: none (no test framework needed).
## Security Domain
Not an application-code phase; the deliverable is process/CI. Controls: scanners run with no registry credentials in env (the `security` job must NOT receive `REGISTRY_TOKEN` or Tauri secrets); gitleaks `--redact`; reports never echo secret values; job runs after publish and cannot alter images.
## Sources
- Primary (measured this session): runner/job container `docker inspect`, probe script in `gitea/runner-images:ubuntu-latest`, local scanner runs, `docker run zaproxy ... -h`, ZAP hook test.
- [CITED] www.zaproxy.org/docs/docker/baseline-scan/ (options, exit codes); GitHub releases API for gitleaks v8.30.1, trivy v0.75.0, osv-scanner v2.6.0, semgrep v1.180.0, ZAP v2.17.0.
- [CITED] gitea.com/actions/gitea-upload-artifact; code.forgejo.org/forgejo/runner/issues/144 (upload-artifact v4 vs GHES); gitea.com/gitea/runner/pulls/917 and blog.gitea.com/release-of-runner-2.0.0 (job summary needs Gitea 1.27).
**Valid until:** 2026-10-16 for vulnerability counts (advisories change daily); 30 days for tool/CI mechanics.
@@ -0,0 +1,227 @@
---
phase: quick-261009-p0m
reviewed: 2026-10-09T21:00:00Z
depth: standard
files_reviewed: 24
files_reviewed_list:
- .gitea/workflows/ci.yml
- .gitea/scripts/security-scan.sh
- .gitea/scripts/zap-baseline.sh
- .gitea/scripts/zap-hooks.py
- .gitleaks.toml
- .semgrepignore
- .gitignore
- .dockerignore
- apps/api/Dockerfile
- apps/web/Dockerfile
- apps/web/next.config.ts
- apps/web/src/next-config.test.ts
- apps/api/src/crypto/crypto.service.ts
- apps/api/src/crypto/crypto.service.spec.ts
- apps/api/src/http-setup.ts
- apps/api/src/http-setup.spec.ts
- apps/api/src/main.ts
- apps/api/src/mail/mail.module.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/package.json
- apps/web/package.json
- package.json
- pnpm-lock.yaml
- apps/desktop/src-tauri/Cargo.lock
findings:
critical: 0
warning: 5
info: 4
total: 9
status: issues_found
---
# Quick 261009-p0m: Code Review Report
**Reviewed:** 2026-10-09
**Depth:** standard
**Files Reviewed:** 24
**Status:** issues_found
## Summary
Reviewed `git diff d15a470..HEAD` (without `pnpm-lock.yaml` details and `.planning`). The lockfiles
were only skimmed for unexpected major bumps: there are none (Next 15.5.19 -> 15.5.27, NestJS
11.1.x -> 11.2.7, nodemailer 9.0.1 -> 9.1.1, undici 7.28.0 -> 7.30.0, vitest 4.1.9 -> 4.1.11,
rustls 0.23.41 -> .45, rustls-webpki .13 -> .15; overrides all stay inside their major).
No finding blocks a push. The checks asked for came out like this.
Verified OK:
- **ci.yml.** The diff only appends. The existing jobs are untouched. The `if:` is identical to the one on
`desktop`. `needs: publish` is correct, and nothing depends on `security`. There is no `secrets.`
in the job, and `timeout-minutes: 30` is set.
- **security-scan.sh.** It ends in `exit 0`, the workflow step adds `|| true`, and every download is
SHA256-checked (cached archive included) before use. Only counts and status words are logged. The tool
config sits in `$WORK` and is removed by the `EXIT` trap. The script's own network calls go only to
GitHub releases, ghcr.io (Trivy DB), semgrep.dev, osv.dev, npm and PyPI. It never contacts Tessera or
alpha. The only registry it touches is the local Docker daemon, via `docker image inspect`.
- **Crypto tag check.** Every ciphertext in the repo comes from `CryptoService.encrypt`, which has used
Node's default 16-byte tag since the first commit (9ec6313, `createCipheriv` without options). No SQL
migration, seed or script writes ciphertext. The LDAP backfill migration only renames a column. All
`decrypt` callers (LDAP, calendar/Exchange, SMTP, DKV, tender mailbox, Nextcloud app password, AutoDNS,
Proxmox) therefore read 16-byte tags, so stored values on alpha/live will not fail. There is no legacy
short-tag format.
- **Removed packages.** `@nestjs-modules/mailer`, `ews-javascript-api` and `handlebars` appear only in
comments. Every non-relative import in `apps/api/src` is declared in `apps/api/package.json`. Under
pnpm's strict layout the removal cannot break a hoisted import.
- **nodemailer 9.1 / undici 7.30.** These are patch/minor bumps inside the major. Usage is limited to
`createTransport`/`sendMail`/`Transporter` and `fetch`/`request`/`Agent`. Nothing in the diff touches
them.
- **Runtime image.** `prisma` is in `dependencies`, so `apps/api/node_modules/.bin/prisma` exists for
`migrate-and-start.sh`. Compose healthchecks use `wget` (busybox), not npm. No repo script runs
`npm`/`npx`/`pnpm` inside the api or web container. `docs/anleitung-betrieb.md` states that
`exec api pnpm`/`npm` no longer works.
- **Web headers vs. embedding.** The only iframes are the XFrame widget and custom modules (foreign
origins, not affected), plus the welcome-mail preview (`srcDoc`, `sandbox=""`). There is no iframe of a
Tessera page. The desktop app navigates its window to the server URL and does not frame it. Nothing
uses `getUserMedia`, geolocation, `navigator.usb` or `PaymentRequest`. The only `window.open` call
already passes `noopener,noreferrer`, so COOP `same-origin-allow-popups` is safe.
- **ZAP.** The hook turns off form processing and form POST, and there is no AJAX spider and no active
scan. The `ghcr.io` image is pinned by digest.
## Warnings
### WR-01: Prisma client for the runtime image is generated only through a silently-failing postinstall, and nothing in CI starts the image
**File:** `apps/api/Dockerfile:33-39`, `apps/api/package.json:13`
**Issue:** The old Dockerfile ran an explicit `RUN prisma generate`, which fails the build on error. Now
the runtime client only comes from the `postinstall` of `apps/api` during `pnpm install --prod`
(`prisma generate || true`). If the engine download or the generate step fails, the error is swallowed.
The image builds, `publish` pushes it, and it dies at start-up (`@prisma/client did not initialize yet`).
It also breaks if a pnpm update stops running workspace-project lifecycle scripts. The proof that it works
(`checks/fresh-db-start.sh`, `task5-image.txt`) lives in untracked `.planning/`, so nothing repeats it
after this commit. The CI `publish` job builds the image but never starts it.
**Fix:**
```dockerfile
RUN pnpm install --frozen-lockfile --prod --filter=@tessera/api...
# Hard-fail if the client was not generated (postinstall swallows errors with `|| true`)
RUN apps/api/node_modules/.bin/prisma generate --schema apps/api/prisma/schema.prisma \
&& find node_modules/.pnpm -path '*/.prisma/client/index.js' | grep -q .
```
Also commit `fresh-db-start.sh` (e.g. under `.gitea/scripts/`) and call it after the image build, or at
least keep it reachable from the release checklist in the Betriebsanleitung.
### WR-02: Semgrep is installed from PyPI without hash pinning, in a job that has the host Docker socket
**File:** `.gitea/scripts/security-scan.sh:254-270`
**Issue:** gitleaks, Trivy and osv-scanner are SHA256-pinned. Semgrep is only version-pinned:
`pipx install semgrep==1.180.0` resolves its transitive dependencies freely. A compromised PyPI
dependency would execute in a job container that mounts `/var/run/docker.sock` (see
`docker-compose.ci.yml`), which is root on the host. The install lands in the persistent
`/opt/hostedtoolcache/tessera-security`. On later runs only `--version | grep -q "^1.180.0"` is checked,
and that executes the cached binary. The plan and docs claim "pinned and checksum-verified scanners";
this one is not. "No secret in the job" limits what leaks, but not the host exposure.
**Fix:** Install from a hash-locked requirements file (`pip install --require-hashes -r semgrep-1.180.0.txt`
into a venv). Alternatively run Semgrep as a container pinned by digest. Do not trust the cached
`semgrep` beyond a version string.
### WR-03: "Never fails and never delays the pipeline" is not guaranteed, and was not exercised on the real Gitea
**File:** `.gitea/workflows/ci.yml:266-295`
**Issue:**
1. Job-level `continue-on-error` and `timeout-minutes` are honored by GitHub, but Gitea Actions support is
version-dependent. The SUMMARY says the first real CI run is still pending. The script itself cannot
fail, so the remaining ways to turn the run red are `actions/checkout`, the runner, and the 30-minute
timeout. `docs/ci-cd-setup.md` already concedes "Job ist rot oder gelb".
2. The runner is documented as the single runner. Every push to `main` therefore holds it for up to 30
minutes after `publish`. The next push's `quality`/`test` waits in the queue, which contradicts the
plan truth "never ... delays quality, test, desktop or publish".
**Fix:** After the first push, check that the run shows green/neutral and not red (`security` red =
unsupported). If the single runner is the bottleneck, move the scan to a scheduled workflow
(`on: schedule`, nightly) plus tags `v*`, or cut the job budget (see WR-04). Keep the existing
`if: gitea.ref == ...` line.
### WR-04: Per-tool timeouts add up to far more than the job timeout, and the summary is only printed at the very end
**File:** `.gitea/scripts/security-scan.sh:264,286,298-300,333,359,375,403,424,583-597`
**Issue:** The sum of the limits is about 600 (pipx) + 120 (corepack) + 3 x 600 (curl) + 900 (gitleaks) +
300 (audit) + 600 (osv) + 1500 (semgrep) + 900 (trivy fs) + 2 x 900 (images), roughly 8,500 s, against
`timeout-minutes: 30` (1,800 s). A slow Semgrep or Trivy run makes the runner kill the whole job before
`summarize` runs. The log then holds no `SECURITY-SUMMARY` lines and the report directory may not be
uploaded. The documented guarantee "the SECURITY-SUMMARY lines in the log are always enough" fails in
exactly the slow case.
**Fix:** Either print a one-line count right after each tool, or keep one global deadline. For example,
compute `DEADLINE=$(( $(date +%s) + 1500 ))` and let `tmo` use `min(limit, DEADLINE-now)`. Also lower
Semgrep to about 600 s.
### WR-05: ZAP basic-auth replacer rule is not scoped to the target and can send alpha credentials to other hosts
**File:** `.gitea/scripts/zap-hooks.py:23-32`
**Issue:** With `ZAP_BASIC_AUTH_FILE` set, a global replacer rule (`initiators=""`, no URL restriction)
overwrites `Authorization` on every request that ZAP sends. That includes any redirect target, and ZAP's
own add-on/update traffic if it is enabled, not only requests to alpha. The credentials are the Basic
Auth of the test server. The same file sits in the report (documented), so the report must not be passed
on. The rule itself adds a second exposure. It only matters when the manual run needs the creds, but it
is avoidable.
**Fix:** Scope the rule to the target, e.g. pass `url=re.escape(target) + '.*'` to `replacer.add_rule`
(the `url` parameter exists in current Replacer API versions), and use `ZAP_TARGET` from `zap_started`'s
`target` argument.
## Info
### IN-01: Hard-coded Yarn path makes the hardening silently disappear on a base-image bump
**File:** `apps/api/Dockerfile:52-53`, `apps/web/Dockerfile:47-48`
**Issue:** `rm -rf ... /opt/yarn-v1.22.22 ...` names one exact version. `node:24-alpine` floats; when the
image ships a different Yarn, `rm -rf` of a missing path succeeds and Yarn (and its transitive
advisories) remain with no build failure. The same line is copied into two Dockerfiles.
**Fix:** `rm -rf /opt/yarn-v* ...` and add `RUN ! command -v npm && ! command -v yarn && ! command -v corepack && node --version`
so the build fails if the removal stops working.
### IN-02: Headers duplicated by the proxy may conflict, contrary to the comment in `next.config.ts`
**File:** `apps/web/next.config.ts:31-40`
**Issue:** The comment says that if the reverse proxy sets the same headers "nothing is gained or lost".
That is true only for identical values. If NPM later adds `X-Frame-Options: DENY` or its own CSP,
browsers treat two conflicting values as the stricter one (or ignore XFO), which could block Tessera
being framed by itself or produce two CSPs. The first public ZAP run (still outstanding per the
protocol) is the point to check it.
**Fix:** After the public-URL ZAP run, record that the proxy sends none of these headers. Leave the
comment otherwise as is, but drop the "nothing lost" claim.
### IN-03: `zap-baseline.sh` leaves the report folder world-writable and breaks on credentials with quotes
**File:** `.gitea/scripts/zap-baseline.sh:92,111`
**Issue:** `chmod 777 "$REPORT_DIR"` is done so the container user (uid 1000) can write, but the report
can contain Basic Auth credentials (documented). Any local user can read it. Separately,
`printf 'user = "%s"\n'` makes curl's config parser stop on a `"` or `\` in the password, which then
aborts (exit 4, fail-closed, but confusing).
**Fix:** Use `chmod 770` and `chown`/`--user "$(id -u):$(id -g)"` for the container instead of 777, and
escape `\` and `"` in `CRED` before writing the curl config.
### IN-04: Shallow clone would silently shorten the gitleaks history scan
**File:** `.gitea/scripts/security-scan.sh:325-337`
**Issue:** `ci.yml` uses `fetch-depth: 0`, but the script has no check. Running it from a shallow local clone
still reports `gitleaks findings=0` for the visible commits only.
**Fix:** `git -C "$ROOT" rev-parse --is-shallow-repository` and emit
`SECURITY-SUMMARY gitleaks ... note=flaches-repository` when true.
---
_Reviewed: 2026-10-09_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
## Fix status
Fixed 2026-10-09 in commits `bf632d9` (code) and `8a1218a` (docs). Not pushed.
| Finding | Status | What was done / proof |
|---------|--------|-----------------------|
| WR-01 | fixed | `apps/api/Dockerfile` (stage `prod-deps`): explicit `prisma generate` plus a check that `.prisma/client/index.js` exists, no `|| true`. Proof: a throwaway Dockerfile with a corrupted `schema.prisma` built the install step (postinstall swallowed the error) and then FAILED at the new step (rc 1). Fresh-DB proof script moved to `.gitea/scripts/image-start-check.sh` (versioned, documented in Entwicklungsanleitung and Betriebsanleitung Kap. 9); on the rebuilt image: `migrationen=63`, `frische-datenbank ok`. CI jobs untouched. |
| WR-02 | fixed | Semgrep runs from `semgrep/semgrep@sha256:529ee8a2...` (digest pinned, `docker create` + `docker cp` in/out, because the job path is not a host path). pipx/PyPI path removed. Exit-0 and SHA256 checks for the other tools unchanged. |
| WR-03 | open, by design | Verify after the push: job `security` shows green/neutral (not red) on the real Gitea, `continue-on-error` and `timeout-minutes` honoured, and the single runner is not blocked (see SUMMARY checklist). Recorded as open row in the protocol. |
| WR-04 | fixed | Global budget `BUDGET_SECONDS=1500`; per-tool limits sum to 1485 s (install 375, checks 1110); `tmo` clamps to remaining time; each tool prints its `SECURITY-SUMMARY` line immediately and appends to `summary.txt`; reasons `zeitgrenze` / `gesamtbudget`. Proven with forced limits (semgrep 2 s, trivy 1 s, budget 6 s). Full run in the runner image: 63 s, rc 0. |
| WR-05 | fixed | Replacer rule gets `url=<escaped target>(?:[/?#].*)?`. Proof through a ZAP daemon proxy: target `127.0.0.1:2000` got the header; `127.0.0.1:20001` (same host, prefix port) and `127.0.0.2:2002` (second host) did not. |
| IN-01 | fixed | `rm -rf /opt/yarn* /usr/local/bin/yarn* ...` plus a loop `command -v` over npm/npx/corepack/yarn/yarnpkg and an `ls` absence check, in both Dockerfiles. Proof: a Dockerfile that leaves npm in place fails the build. |
| IN-02 | fixed | Comment in `apps/web/next.config.ts` now says differing proxy values apply side by side (stricter wins) and points to the public ZAP run. `next-config.test.ts` 4/4, biome clean. |
| IN-03 | fixed | Report dir `chmod 700` (uid 1000 case); other uids hand the dir over to 1000 and back via a short container run (path exercised with a faked uid). Credentials only as base64 in a 0600 header file (`curl -H @file`) and env file; no curl config string. Proof: password with `"` and `\` passes the pre-check, ZAP run 0 POST, 8 of 9 requests with header (the 9th is the pre-check). |
| IN-04 | fixed | `git rev-parse --is-shallow-repository`; gitleaks line reads `findings=0 unvollstaendig (flacher Klon: ...)`. Proven with a `--depth 1` clone. |
@@ -0,0 +1,305 @@
---
phase: quick-261009-p0m
plan: 01
status: complete
tasks_done: [1, 2, 3, 4, 5, 6]
plan_head_before: d15a470c5abed0c08a37ef098ea0e0b8c7676a43
plan_head_after: b0858896573693f06c2fa17e065470ab864fbbeb
commits: 9
actuals:
tasks: 6
commits: 9
---
# Quick 261009-p0m: Sicherheitsprotokoll + CI-Sicherheitspruefungen -- Summary
## Task 1: Tracer -- Job `security` als reiner Bericht (erledigt)
**Commit:** `3d00be8` ci(quick-261009-p0m): Sicherheitspruefung als reiner Bericht in der Pipeline (nicht gepusht)
Dateien im Commit: `.gitea/scripts/security-scan.sh` (neu, ausfuehrbar), `.gitleaks.toml` (neu), `.semgrepignore` (neu), `.gitignore`, `.dockerignore`, `.gitea/workflows/ci.yml` (nur Anhaengen: Kopfzeile + Job `security`, keine andere Zeile geaendert), `docs/ci-cd-setup.md` (§4 fuenf Jobs, Unterabschnitt, §6-Eintrag).
Nicht eingecheckt (Vorgabe des Orchestrators, alles unter `.planning/` bleibt unversioniert): `baseline/.gitignore`, `baseline/SUMMARY.txt` (Block "nach Ausnahmelisten"), `checks/task1-runner-full.txt`, diese Datei.
### Gates (alle gruen, Sentinel `task1 ok` gedruckt)
- ci.yml per js-yaml geparst: Jobs `quality,test,desktop,publish,security`; `needs: publish`, `continue-on-error: true`, **`timeout-minutes: 30` (Auflage des Plan-Checkers, Gate erweitert)**, Bedingung main/Tags v*, kein `secrets.` im Job, kein anderer Job haengt an `security`, jeder `run` endet auf `|| true`, `fetch-depth: 0`, `upload-artifact@v3` mit `continue-on-error`.
- `--print-plan`: main -> `api:beta`/`web:beta`, `v9.9.9` -> `api:live`, Zweig `live` -> `scan-image keine ...`.
- `.gitleaks.toml` strukturell (tomllib): `useDefault`, jede Liste mit Beschreibung; nur das Fixture-Verzeichnis als Ordner, alles andere einzelne verankerte Datei mit `targetRules`; `de.json` zusaetzlich `condition=AND` + `regexTarget=line` + Regex auf die eine Zeile `"authMethodPassword": "Benutzer/Passwort"`.
- gitleaks ueber die volle Historie (1369 Commits inkl. neuem Commit): **0 Treffer** (vorher 20).
- Voller Lauf im echten Runner-Abbild `gitea/runner-images:ubuntu-latest` (Netz `gitea`, Socket-Mount, frisches Werkzeug-Volume, Wegwerf-Klon mit Kanarienvogel-Token): rc=0, gitleaks `findings=1` (nur Kanarienvogel, github-pat), trivy-fs `secrets=1` (nur Kanarienvogel), alle uebrigen Werkzeuge und beide Abbilder gezaehlt -> Annahme A1 bestaetigt (Job-Container scannt Abbilder des Host-Daemons). Semgrep per pipx im Container installiert und gelaufen.
- Offline-Lauf (`--network none`, schreibgeschuetzter Klon): rc=0, 12 `skipped reason=`-Zeilen (>= 7 verlangt).
- Host-Installation: gitleaks 8.30.1, trivy 0.75.0, osv-scanner 2.6.0 liegen in `~/.cache/tessera-security` (Semgrep dort uebersprungen: kein pipx). pnpm 9.15.0 aus dem PATH.
- Wegwerf-Klon, Volume und root-eigene Berichte entfernt; Token steht in keiner Datei ausserhalb des Klons (geprueft: `ghp_` in der Nachweisdatei = 0 Treffer).
### Zahlen nach Ausnahmelisten (Details in `baseline/SUMMARY.txt`)
gitleaks 0; pnpm audit --prod C5 H73 M68 L5; osv-scanner 56 (pnpm-lock + Cargo.lock; Grundlinie 43 nur pnpm-lock); Semgrep 34 Treffer (ERROR 2, WARNING 29, MEDIUM 3) / 4 Scanfehler (PartialParsing, 0 Zeitueberschreitungen), vorher 58/59; trivy-fs C5 H73 M70 L5, misconfig 2, secrets 0; trivy-image api C6 H116 M99 L7, web C2 H19 M22 L1.
## Deviations from Plan
1. **[Orchestrator-Vorgabe] Nichts unter `.planning/` eingecheckt.** Der Plan nennt `baseline/.gitignore`, `baseline/SUMMARY.txt` und `checks/task1-runner-full.txt` als Commit-Dateien; sie liegen auf der Platte, bleiben aber unversioniert. Gates lesen sie von der Platte und sind gruen.
2. **[Rule 2 / Plan-Checker] `timeout-minutes: 30`** am Job ergaenzt (im Plan nicht vorgesehen), Gate und `docs/ci-cd-setup.md` (Unterabschnitt + §6) sind angepasst.
3. **Trivy-Ausnahme** fuer die Testschluessel liegt als zur Laufzeit erzeugte `trivy-secret.yaml` (allow-rule auf den Fixture-Ordner) im Arbeitsordner des Skripts, nicht als Repository-Datei; wirkt nur auf den Secret-Scanner (trivy-fs secrets 7 -> 0, Kanarienvogel weiterhin gefunden).
4. Der Skript-Aufruf `all` rechnet Semgrep-Zeitlimit pro Regel/Datei `--timeout 20` (Plan: "per-file timeout"); Semgrep kennt nur dieses eine Limit.
## Hinweise fuer Task 2 (Protokoll)
- Zahlen "roh" aus `baseline/SUMMARY.txt` (oberer Teil) und "nach Ausnahmelisten" (unterer Block "nach Ausnahmelisten (2026-10-09, Task 1)"). Unterschied osv: 43 (nur pnpm-lock) vs. 56 (mit Cargo.lock). Semgrep roh 58 -> 34; die 4 Scanfehler sind alle PartialParsing (ci.yml, beide Dockerfiles), keine Zeitueberschreitungen mehr.
- Abbild-Zaehlung des Skripts summiert OS- und Node-Pakete (api M99, web M22; Forschung nannte M98/M21 nur Node + je 1 Alpine).
- Neue Dateien: `.gitleaks.toml` (6 Ausnahmegruppen), `.semgrepignore`; ci-cd-setup.md beschreibt Version-Anheben-Rezept (Version + URL + SHA256 gemeinsam).
- Hostwerkzeuge liegen bereit (Precondition von Task 2 erfuellt: `git log -- .gitea/scripts/security-scan.sh` zeigt 3d00be8; `gitleaks version` -> 8.30.1).
- Erster echter CI-Lauf steht aus (Orchestrator/Nutzer-Checkliste nach dem Push): Job `security` muss gelb/gruen laufen, `SECURITY-SUMMARY`-Zeilen erscheinen, `upload-artifact@v3` ist best effort (Annahme A4 ungeprueft). Der Skript-Lauf ist bis auf den Artefakt-Schritt im Runner-Abbild nachgestellt.
- Plattenstand nach Task 1: 80 % (36 GB frei), unter der 85-%-Schwelle.
- Der Eintrag `semgrep ... skipped reason=kein-pipx` auf dem Host ist erwartet.
## Task 2: Sicherheitsprotokoll, Verweise, Pflegeregel, CHANGELOG (erledigt)
**Commit:** `5b36b9a` docs(quick-261009-p0m): Sicherheitsprotokoll mit Bestandsaufnahme und erster Vollpruefung (nicht gepusht)
Dateien im Commit: `docs/sicherheitsprotokoll.md` (neu, 227 Zeilen), `docs/README.md` (Absatz "Ebenfalls dabei"), `docs/anleitung-betrieb.md` (Satz im Kopf + Absatz in Kapitel 8 "Automatische Sicherheitspruefung"), `docs/anleitung-entwicklung.md` (Inhaltsverzeichnis 9 = Sicherheitspruefungen, 10 = Konventionen; neuer Abschnitt mit "So pflegen Sie dieses Protokoll", "Die Pruefung in der Pipeline", "Pruefungen von Hand wiederholen", "Ausnahmelisten"), `CHANGELOG.md` (ein Bullet "Sicherheit: ..." unter Neu; keine vierte Gruppe).
Nicht eingecheckt (Vorgabe): `checks/link-check.py` (liegt auf der Platte), diese Datei.
### Gates (Sentinel `task2 ok` gedruckt)
- Alle sieben Ueberschriften exakt, alle 20 Schluesselwoerter, keine Du-Form, kein "Lizenz", kein "Mandant" im Protokoll (strenger als verlangt), " Sie " vorhanden.
- gitleaks `dir` auf das Protokoll: keine Funde. Link-Pruefer: 8 Links geprueft, 0 kaputt (Anker `#sicherheitsprüfungen` aufgeloest).
- CHANGELOG: nur Neu/Geaendert/Behoben, Bullet "- Sicherheit: ... Sicherheitsprotokoll ...".
### Inhalt des Protokolls (fuer die Folgeaufgaben wichtig)
- Abschnitte: Auf einen Blick, So lesen Sie, Wie die Pruefungen arbeiten (Unterabschnitte je Werkzeug), Bisherige Sicherheitspruefungen (Tabelle der acht Pruefungen, Haertung Adressschutz 09.10., Bedrohungsbetrachtungen 179 Plaene / 884 Kennungen frisch ausgezaehlt, Datentrennung als bestehender Schutz ohne Schaltzustand, Tabelle weiterer Auftraege, Fehlerregister, Tests, "Was es bisher nicht gab"), Erste vollstaendige Pruefung (Tabelle roh / nach Ausnahmelisten), Einordnung der Befunde (23 Zeilen, alle Abhaengigkeits-Zeilen noch "offen"), Verlauf (5 Eintraege, neueste oben).
- **Task 3** muss ergaenzen: Unterabschnitt "Aussenpruefung (ZAP)" unter "Wie die Pruefungen arbeiten", Verlauf-Eintrag fuer den ersten ZAP-Lauf (oben), "Auf einen Blick", Kapitel 9 der Betriebsanleitung. Das Protokoll nennt den Alpha-Hostnamen bewusst noch nirgends.
- **Tasks 4/5/6** muessen Stand-Spalte der Zeilen von "offen" auf "behoben (Datum)" setzen: Next.js, proxy-addr, handlebars (Paket entfernt), nodemailer, undici, multer, adm-zip 0.6.1, "Uebrige Bausteine", Entwicklungswerkzeuge im api-Abbild, npm im web-Abbild, rustls, AES-GCM-Tag; Zeilen xlsx, node-forge, glib bleiben offen; Abschnitt "Stand nach der Behebung" mit den Zahlen der Abschlussmessung; "Auf einen Blick" und Verlauf nachziehen. Die Zeile "Fehlende Gesundheitspruefung web" steht auf "offen".
- Wahrheitsgehalt der Bestandsaufnahme (aus den Dateien/Git nachgeprueft): Phase 08 Fix in `eebceb2` (CR-01, CR-02, WR-01..05, IN-01 behoben; IN-02/IN-03 weiter offen, im Code bestaetigt); Phase 16 vier Warnungen behoben, IN-01/IN-02 offen (im Code bestaetigt); Phase 18 vier behoben, zwei Hinweise nicht bearbeitet; dts IN-01/IN-02/IN-04 uebersprungen (Datenbankaenderung/Entwurf noetig); dkv alle 11 inkl. EXTRA behoben; ikt alle 15 behoben. Summe 89 Befunde: 76 behoben, 13 Hinweise offen. Fehlerregister: 39 Eintraege, 25 behoben, 1 zurueckgestellt, 13 offene gehoeren alle zur Datentrennung zwischen Organisationen und werden im Protokoll nur in einem Satz erwaehnt (D-07).
- Triage-Fakten, die ich gegen die Rohdaten geprueft habe: adm-zip-Meldung ohne Korrektur ist "Mittel" (Symlinks beim Entpacken); node-forge-Meldung betrifft die Pruefung von Unterschriften; xlsx: Prototype-Pollution und ReDoS; `trust proxy` wird im Quelltext nirgends gesetzt.
## Deviations (Task 2)
Keine fachliche Abweichung. Kleine Anmerkung: Der Plan nennt `.planning/` nicht als Commit-Ort fuer `link-check.py`; er bleibt gemaess Orchestrator-Vorgabe unversioniert und das Gate liest ihn von der Platte. Die Anhaengsel-Zeile der Anlage des Commits nutzt wie verlangt die Opus-Zeile als Trailer.
## Hinweise fuer Task 3
- Hostwerkzeuge liegen bereit. Das Protokoll wartet auf den ZAP-Teil (siehe oben). `link-check.py` kann nach jeder Aenderung der Dokumente wiederholt werden (`python3 -I .planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/link-check.py`).
- Beim Ergaenzen von Kapitel 9 der Betriebsanleitung den Anker-Stil beibehalten: Querverweise im Protokoll zeigen auf `anleitung-entwicklung.md#sicherheitsprüfungen`.
- Wortregeln fuer das Protokoll beibehalten (Gate von Task 2 prueft sie nicht fuer Task 3): Sie-Form, kein "Lizenz", kein "Mandant", keine internen IPs.
## Task 3: ZAP-Grundpruefung (erledigt, mit Abweichung beim Ziel)
**Commit:** `13571df` ci(quick-261009-p0m): ZAP-Grundpruefung gegen alpha vor Freigaben, erster Lauf (nicht gepusht)
Dateien im Commit: `.gitea/scripts/zap-baseline.sh` (neu, ausfuehrbar), `.gitea/scripts/zap-hooks.py` (neu), `docs/sicherheitsprotokoll.md`, `docs/anleitung-betrieb.md` (Kapitel 9, Schritt 2 "Pruefung von aussen" vor "Zusammenfuehren und taggen"), `docs/anleitung-entwicklung.md` (Unterabschnitt "Pruefung von aussen (ZAP)"), `CHANGELOG.md` (Sicherheits-Bullet um die Aussenpruefung ergaenzt). Nicht eingecheckt (Vorgabe): `checks/zap-testserver.py`, `checks/task3-zap-local.txt`, `checks/zap-alpha-2026-10-09.txt`, `baseline/SUMMARY.txt` (ZAP-Zeile angehaengt), diese Datei. `baseline/zap-hook-basic-auth.py` geloescht.
### Abweichung (Orchestrator-Entscheidung)
Die oeffentliche Adresse von alpha war vom Dev-Host nicht erreichbar (Zeitueberschreitung auf Port 443, 3 Versuche; allgemeines Internet ging). Auf Anweisung des Orchestrators wurde der erste Lauf **direkt gegen die Anwendung auf alpha ohne Proxy** ausgefuehrt (interne Adresse, Port 3000, `/login` = 200, Version per `/api-proxy/health/version`: `v1.10.1-80-gd15a470`, Kanal beta). Das Skript hat das Ziel als Parameter `ZAP_TARGET` (Standard weiter die oeffentliche Adresse). Im Protokoll steht nirgends die IP, nur "interne Adresse des Testservers"; das Protokoll sagt ausdruecklich, dass der Lauf ueber die oeffentliche Adresse noch folgt. Das Gate wurde angepasst (ZAP-SUMMARY-Zeile mit beliebigem Ziel statt festem Hostnamen; zusaetzliche Pruefungen: keine private IP in den Docs, keine Du-Form/Lizenz/Mandant, gitleaks auf dem Protokoll). Sentinel `task3 ok` gedruckt.
### Lokaler Beweis (Passivitaet)
Testserver mit POST-Formular, ZAP ueber die Docker-Bruecke, `ZAP_SPIDER_MINUTES=1`: `lauf-ohne-anmeldung POST=0 GET=8`; `lauf-mit-anmeldung POST=0 auth_ja=8 auth_nein=1` (die eine Anfrage ohne Kopf ist die Vorabpruefung des Skripts). Auch der Hakenweg fuer die Anmeldedatei funktioniert. Beim Test fiel auf und wurde behoben: bei vorzeitigem Abbruch (SIGPIPE) blieben die temporaeren Dateien mit dem Kopf liegen, deshalb wird die Umgebungsdatei jetzt sofort nach dem Container-Lauf entfernt und die Falle faengt auch HUP/PIPE ab (beide Reste von /tmp geloescht). Der Bericht enthaelt bei Nutzung der Anmeldedatei die gesendeten Kopfzeilen samt Zugangsdaten (in Skript-Kopf und Entwicklungsanleitung vermerkt; Berichte liegen nur in `security-reports/`, ignoriert).
### Erster Lauf gegen alpha (2026-10-09 19:41, Ziel: Anwendung direkt, 28 URLs, 0 POST)
ZAP-SUMMARY: **hoch 0, mittel 2, niedrig 6, info 3** (11 Meldungsarten).
- Mittel: fehlende Content-Security-Policy; fehlender Schutz gegen Einrahmen (Clickjacking) -- beide auf /login, /reset-password.
- Niedrig: Cross-Origin-Embedder-/Opener-/Resource-Policy fehlen; Permissions-Policy fehlt; X-Content-Type-Options fehlt; X-Powered-By (Next.js-Standard).
- Info: Content-Type fehlt (Umleitungen 307 fuer nicht angemeldete Besucher), nicht speicherbar, speicherbar.
- Einordnung im Protokoll: Kopfzeilen = offen ("Aufgabe des Proxys, beim oeffentlichen Lauf erneut pruefen; sonst in der Anwendung setzen"; `next.config.ts` setzt keine Kopfzeilen und kein `poweredByHeader: false`), HTTPS/HSTS = nicht beurteilt, offen (Proxy), Infos = bewusst akzeptiert. Es wurde kein Tessera-Code und keine Proxy-Konfiguration geaendert.
## Hinweise fuer Task 4 und folgende
- Protokoll: neue Tabelle "Pruefung von aussen gegen alpha" und fuenf neue Zeilen in "Einordnung der Befunde" (Kopfzeilen mittel/niedrig, X-Powered-By, HTTPS/Proxy, Infos); Verlaufseintrag steht oben. Task 6 soll "Auf einen Blick" (Bullet "Pruefung von aussen") und diese Zeilen nachziehen; ein spaeterer Lauf ueber die oeffentliche Adresse waere eine weitere Tabellenzeile + Verlaufseintrag.
- Angebot an den Nutzer (nicht ausgefuehrt, kein Auftrag): `poweredByHeader: false` und Sicherheits-Kopfzeilen in `apps/web/next.config.ts` waeren eine kleine, sichere Haertung; vorher klaeren, ob der Proxy sie schon setzt.
- `link-check.py` laeuft gruen (11 Links). Hostwerkzeuge und das ZAP-Abbild (3.8 GB, per Digest) liegen lokal; Plattenstand 80 %.
## Task 4: Abhaengigkeiten innerhalb der Hauptversionen (erledigt)
**Commit:** `cf982e9` fix(quick-261009-p0m): Abhaengigkeiten innerhalb der Hauptversionen aktualisiert, zwei unbenutzte entfernt (nicht gepusht)
Dateien im Commit: `package.json` (neu: `pnpm.overrides`, 15 Eintraege), `pnpm-lock.yaml`, `apps/api/package.json`, `apps/web/package.json`, `apps/api/src/mail/mail.module.ts` und `apps/api/src/calendar/providers/exchange.provider.ts` (nur falsch gewordene Kommentare), `apps/desktop/src-tauri/Cargo.lock`, `docs/sicherheitsprotokoll.md`, `docs/anleitung-entwicklung.md` (neuer Abschnitt `### Abhängigkeiten aktualisieren`), `CHANGELOG.md` (dritter "Sicherheit:"-Bullet unter Behoben). Nicht eingecheckt (Vorgabe): `checks/mail-smoke.sh`, `checks/task4-audit.txt`, diese Datei.
### Audit vorher / nachher (`checks/task4-audit.txt`)
| | critical | high | moderate | low | gesamt |
|---|---|---|---|---|---|
| prod vorher | 5 | 73 | 68 | 5 | 151 |
| prod nachher | **0** | **9** | 3 | 0 | 12 |
| alle vorher | 7 | 85 | 74 | 5 | 171 |
| alle nachher | 2 | 9 | 5 | 0 | 16 |
Verbleibend prod (12): xlsx 2 hoch (keine Korrektur), node-forge 1 hoch (keine Korrektur), sharp 3 hoch (Korrektur erst 0.35 = andere 0.x-Minor), deepmerge-ts 1 hoch (Prisma-Werkzeugkette, Korrektur erst v8), nodemailer 2 hoch + 3 mittel (Korrektur erst in nodemailer 10 = neue Hauptversion). Verbleibend nur dev: tinypool 1.1.1 (2 kritisch) + vitest/@vitest/mocker 3.2.6 der API (2 mittel), Korrektur jeweils nur in neuer Hauptversion (vitest 4 / tinypool 2).
### Was geaendert wurde
- Entfernt: `@nestjs-modules/mailer`, `ews-javascript-api` (vorher per grep nachgewiesen: kein Import in `apps/`; Exchange bleibt httpntlm + rohes SOAP). 232 Paketnamen entfallen aus dem Lockfile.
- Direkt angehoben: next ^15.5.27 (installiert 15.5.27); @nestjs/common/core/platform-express ^11.2.7 (multer 2.4.0); nodemailer ^9.1.1; undici exakt 7.30.0; adm-zip ^0.6.1; csv-parse ^7.0.3; vitest (web) ^4.1.11 (noetig, weil @vitest/mocker < 4.1.11 gemeldet).
- Overrides (alle innerhalb derselben Hauptversion, 15 Stueck): baseline-browser-mapping, brace-expansion (1.x und 5.x), browserslist 4, fast-uri 3, ip-address 10, js-yaml 4, nanoid 3, nodemailer 9, postcss 8, proxy-addr 2, qs 6, source-map-js 1, underscore 1, undici 7 (auf 7.30.0, damit nur eine undici-Kopie im Baum bleibt). Selektorform `name@>=N <N+1` (bzw. `<2` fuer brace-expansion 1.x) wirkt; per Lockfile geprueft.
- Prisma weiter 6.19.3, Next 15, NestJS 11, React 19.2.7.
- rustls 0.23.41 -> 0.23.45; `cargo update -p rustls --precise` zog zusaetzlich `rustls-webpki` 0.103.13 -> 0.103.15 mit. `cargo check` und `cargo clippy` fehlerfrei, keine Warnungen.
- Nichts musste zurueckgepinnt werden (kein Baustein als "offen wegen Bruch").
### Neue-Namen-Pruefung (T-p0m-SC)
Vergleich der Paketnamen der `packages:`-Sektion vor/nach: **0 neue Namen**, 232 entfallen. Kein Vater noetig. (Neue Versionen vorhandener Namen sind keine neuen Namen.)
### Gates (Sentinel `task4 ok` gedruckt, Kette aus dem Plan wortgetreu)
frozen-Lockfile-Install, Versionspruefung (pakete ok), rustls 0.23.45, cargo check/clippy, `pnpm type-check`, `pnpm lint`, API-Tests 169 Dateien / 3573 Tests, Web-Tests 160 Dateien / 1945 Tests, Audit nachher (0 kritisch, 9 < 73 hoch), `docker compose up -d --build --wait api web`, `/login` = 200, e2e-changelog, e2e-preload-api, e2e-cert all, nc-test-setup, e2e-files, e2e-transfer, e2e-shares all, mail-smoke (`mail ok`), Anleitungs-Ueberschrift, >= 3 "Sicherheit:"-Bullets. Link-Pruefer: 11 Links, 0 kaputt.
## Deviations (Task 4)
1. **[Rule 1] `checks/mail-smoke.sh` liest die E-Mail-Adresse aus `GET /users`, nicht aus `/auth/me`.** `/auth/me` liefert kein E-Mail-Feld (Plan-Annahme falsch); `/users` (als admin) liefert es. Der Test vorher auf dem unveraenderten Stack gruen (`mail ok`), danach ebenfalls; Mailhog `total` waechst.
2. **Rate-Limit beim Rerun:** Ein zweiter Lauf von `e2e-shares.sh` innerhalb weniger Minuten laeuft in das Anlegelimit (`tooManyShares`, 429, 320 s). Kein Fehler im Code; nach 5,5 Minuten Pause lief die Kette komplett gruen. Bei jedem Wiederholen: erst warten.
3. **vitest (web) ^4.1.11** zusaetzlich zu den im Plan genannten Bumps (Audit meldete @vitest/mocker unter 4.1.11); Minor-Bump im selben Major, alle Web-Tests gruen.
4. Protokoll in Task 4 laut Plan aktualisiert (Verlauf, Zeilen in "Einordnung der Befunde", "Auf einen Blick"); Zeile "Mailversand nodemailer" bleibt "offen" (Rest nur mit Hauptversion 10), Zeile "Übrige Bausteine" aufgeteilt in "behoben" und eine neue "offen"-Zeile fuer sharp und deepmerge-ts.
## Hinweise fuer Task 5 und 6
- Stack laeuft aus Task 4 gebaut (api, web, db, mailhog healthy); Plattenstand 80 % nach `docker image prune -f` (vorher kurzzeitig 84 %).
- **Protokoll-Zeilen noch zu schliessen:** "Entwicklungswerkzeuge im Server-Abbild", "Paketverwalter npm im Node-Grundabbild" (Task 5), "Laenge der Echtheitspruefung bei AES-GCM" (Task 5); Task 6 muss "Stand nach der Behebung" mit den Abschlusszahlen, "Auf einen Blick" (Bullet "Was noch zu tun ist" nennt bereits Abbilder + AES-GCM) und Verlauf nachziehen. Offen bleiben: xlsx, node-forge, glib, nodemailer (Hauptversion 10), sharp, deepmerge-ts, Gesundheitspruefung web, Kopfzeilen/HTTPS.
- Die Abbild-Trivy-Zahlen im Protokoll ("Erste vollstaendige Pruefung") sind noch die Rohwerte; Task 6 misst die neuen.
- Der Rest der api-Abhaengigkeiten ist unveraendert; die alten Trivy-Abbild-Zaehlungen (api C6 H116) sinken durch Task 4 bereits deutlich -- erst die Abschlussmessung zeigt es.
- **CLAUDE.md-Zeilen (D-26), die jetzt hinterherhinken (nicht angefasst):** Next.js 15.5.19 -> 15.5.27; NestJS 11.1.27 -> 11.2.7; Vitest (apps/web) 4.1.9 -> 4.1.11. Express 5.2.1 und Prisma 6.19.3 unveraendert. Der Eintrag "Prisma: pin exactly" stimmt weiter (Lockfile 6.19.3; `package.json` hat `^6.0.0`, das war schon vorher so).
- Im Desktop-Lockfile hat sich `Cargo.lock` geaendert: der naechste CI-Push auf main baut die Desktop-Pakete neu (erwartet, D-24).
- Die Dev-Bausteine tinypool/vitest 3 (API-Tests) sind nur mit einem vitest-4-Wechsel der API zu beheben (neue Hauptversion der Testwerkzeuge, nicht Teil dieses Auftrags); im Protokoll ist das als Verlaufsnotiz genannt, aber nicht als eigene Tabellenzeile.
- Orchestrator-Frage fuer spaeter: nodemailer 10 ist die groesste uebrige Produktionsposition (2 hoch, 3 mittel); ein Wechsel waere ein eigener kleiner Auftrag (Nutzung beschraenkt auf `createTransport`/`sendMail`).
## Task 5: AES-GCM-Tag, Laufzeitabbilder ohne Entwicklungswerkzeuge, Schutz-Kopfzeilen (erledigt)
**Commits (nicht gepusht):**
- `072f955` fix(quick-261009-p0m): AES-GCM-Entschluesselung verlangt 16-Byte-Tag (Teil A, Tests zuerst rot)
- `2a8a7d4` build(quick-261009-p0m): Laufzeitabbilder ohne Entwicklungswerkzeuge (Teil B + Doku)
- `fceec19` fix(quick-261009-p0m): Schutz-Kopfzeilen in der Weboberflaeche, X-Powered-By abgeschaltet (Zusatzauftrag des Orchestrators)
Nicht eingecheckt (Vorgabe): `checks/task5-image.txt`, `checks/fresh-db-start.sh`, diese Datei.
### Teil A (AES-GCM)
Tests zuerst: 5 neue Faelle in `crypto.service.spec.ts` (4 Byte, 17 Byte, leer, gekipptes Bit, Rundlauf inkl. leerem Text und Umlauten). Vor der Aenderung **3 rot** (4 Byte, 17 Byte, leer); echt unsicher war nur der 4-Byte-Fall (Node akzeptierte ihn und entschluesselte), 17 Byte und leer warf Node schon, aber mit anderer Meldung als die neue `16 bytes`-Pruefung (daher rot); das gekippte Bit wurde schon vorher abgelehnt. Danach 13/13 gruen. `decrypt` prueft `authTag.length === 16` vor dem Decipher und uebergibt `authTagLength: 16` (auch bei `createCipheriv`, Ausgabe unveraendert). `biome format --write` + `biome check --write` (sortierte die Imports und brach alte Zeilen im Spec auf 100 Zeichen um, das Format der Projektkonfiguration; daher groesserer Diff im Spec als nur die neuen Tests).
### Teil B (Abbilder) -- umgesetzt, kein Rueckbau noetig
- `apps/api/Dockerfile`: neue Stufe `prod-deps` (kopiert Manifeste, Lockfile, Workspace-Datei und `apps/api/prisma/`, dann `pnpm install --frozen-lockfile --prod --filter=@tessera/api...`; der `postinstall` von apps/api erzeugt den Prisma-Client dort, weil `prisma` in `dependencies` liegt). Laufzeitstufe `FROM node:24-alpine`, entfernt als Erstes `/usr/local/lib/node_modules/{npm,corepack}`, `/opt/yarn-v1.22.22`, `/usr/local/bin/{npm,npx,corepack,yarn,yarnpkg}`; `node_modules` kommen aus `prod-deps`; die lange `.prisma`-COPY-Zeile mit festem Pfad entfaellt. `apps/web/Dockerfile`: nur dieselbe Entfernung in der Laufzeitstufe.
- **Groessen und Fundzahlen** (`checks/task5-image.txt`, Trivy 0.75.0 `--scanners vuln`, `--image-src docker`, Betriebssystem + Node-Pakete):
| Abbild | Groesse vorher | nachher | critical | high | medium | low |
|---|---|---|---|---|---|---|
| api vorher (nach Task 4) | 1,59 GB (1592651888 B) | | 3 | 45 | 41 | 2 |
| api nachher | | 1,12 GB | **0** | **6** | 4 | 0 |
| web vorher (nach Task 4) | 359 MB | | 0 | 11 | 13 | 1 |
| web nachher | | 359 MB | 0 | **3** | 1 | 0 |
Die Web-Groesse bleibt gleich: `rm` in einer spaeteren Schicht verkleinert das Abbild nicht (nur die Sicht von Trivy); das ist im Protokoll so erklaert und die Doku verspricht "kleiner" nur fuer das Server-Abbild. Die Rohwerte der ersten Messung im Protokoll (api C6 H116, web C2 H19) bleiben dort als "Erste vollstaendige Pruefung"; Task 6 misst neu.
- **Gates (Sentinel `task5 ok`, Kette aus dem Plan wortgetreu, Log `scratchpad/task5-gate.log`):** Crypto-Tests, biome, `authTagLength` vorhanden, `tsc --noEmit`, komplette API-Tests, `docker compose up -d --build --wait api web`, `/login` = 200, `@prisma/client` loest aus `/app/apps/api` auf, `node` im web-Container, **keine** Paketverwaltung in api und web, keine Entwicklungswerkzeuge in `/app/node_modules/.pnpm` (vitest, tinypool, turbo, biome, Nest-CLI, schematics: alle weg), `@prisma+client@` vorhanden, **`fresh-db-start.sh` -> `migrationen=63`, `frische-datenbank ok`**, Fundzahl nachher < vorher, "keine Paketverwaltung" in der Betriebsanleitung, e2e-cert all, e2e-changelog, nc-test-setup, e2e-files, e2e-transfer, mail-smoke (`mail ok`), "Zugangsdaten"-Bullet, Link-Pruefer (12 Links, 0 kaputt). Zusaetzlich vorab ein eigener Lauf der e2e-Kette (`E2E-ALL-OK`).
- apk (Paketverwaltung des Betriebssystems) bleibt im Abbild; die Vorgabe nannte nur npm, npx, corepack und yarn.
- Rueckbau/Fallback: **nicht benutzt**. Keine devDependency wird zur Laufzeit importiert (Start gegen frische DB und alle Rauchtests gruen).
### Zusatzauftrag: Schutz-Kopfzeilen (eigener Commit `fceec19`)
- `apps/web/next.config.ts`: `poweredByHeader: false` und `headers()` fuer `/:path*`: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `Content-Security-Policy: frame-ancestors 'self'` (nur diese Richtlinie), `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()`, `Cross-Origin-Opener-Policy: same-origin-allow-popups`. Keine COEP/CORP (koennten eingebettete Bilder/Kacheln sperren, nichts zu schuetzen).
- Vorpruefung im Code: Zwischenablage nur `clipboard.writeText` (clipboard-write bleibt erlaubt, per `featurePolicy` im Browser bestaetigt: clipboard-write und fullscreen true, camera und geolocation false); Benachrichtigungen haben keine Permissions-Policy-Direktive; keine Nutzung von getUserMedia/Geolocation/Zahlung/USB. Alle `window.open`/`target=_blank` tragen `noopener`; **kein** `window.opener`, `postMessage` oder `BroadcastChannel` im Web-Quelltext -> COOP sicher. Nextcloud Login Flow v2 ist ein einfacher `<a target=_blank rel=noopener>` (Abfrage laeuft serverseitig). Desktop: Tauri navigiert das Hauptfenster direkt auf die Server-Adresse (`window.navigate`), kein Rahmen. XFrame-Kacheln/eigene Module setzen `sandbox` und `allow=""` und rahmen fremde Seiten ein -- die Kopfzeilen von Tessera betreffen nur das Einrahmen von Tessera.
- API: Express sendete `X-Powered-By: Express` (vorher per `curl -I` bestaetigt). `configureHttp` ruft jetzt `app.disable('x-powered-by')` (Typ `HttpApp` auf `NestExpressApplication`, `main.ts` erzeugt `NestFactory.create<NestExpressApplication>`); Test in `http-setup.spec.ts` (der Test "Anfragelog und Cookies kommen zuerst" ignoriert die `disable`-Aufrufe).
- Tests: neu `apps/web/src/next-config.test.ts` (4 Faelle; eine Konfigurationsdatei-Test gab es vorher nicht). Web-Suite gesamt 161 Dateien / 1949 Tests, `pnpm lint`, `pnpm type-check` gruen.
- **Pruefung am neu gebauten lokalen Stack:** `curl -I /login`, `/` (307) und 404-Seiten tragen alle Kopfzeilen, **kein** `X-Powered-By` bei Web und API (auch nach dem Neubau auf HEAD erneut geprueft). Der Pfad `/api-proxy/...` (Umleitung zur API) traegt die Web-Kopfzeilen nicht, nur die Antwort der API; sie sind fuer JSON-Antworten nicht noetig, ZAP hat sie dort nicht beanstandet (nur auf `/login`, `/reset-password`).
- **Browser (Playwright-Kern, headless Chromium, Dunkelmodus, `scratchpad/smoke/*.cjs`):** Anmeldung und Dashboard, 0 Konsolenmeldungen (kein Hinweis auf unbekannte oder verletzte Richtlinien); Zertifikatsmanager laedt, Testzertifikat + Schluessel hochgeladen, "Fullchain herunterladen" liefert `p0m-smoke.test-fullchain.pem`; Dateien: "Im Browser anmelden" -> Link oeffnet neuen Tab auf `.../login/v2/flow`, `window.opener === null`; eigenes Modul (https://example.com/) im Rahmen geladen (Modul danach geloescht, API 200). Der MCP-Browser stand dem Agenten nicht zur Verfuegung, daher das Playwright-Paket aus dem npx-Cache.
- Protokoll: Zeilen "Fehlender Schutz gegen Einrahmen", "Weitere fehlende Schutz-Kopfzeilen", "X-Powered-By" jetzt "behoben in der Anwendung, beim oeffentlichen Lauf erneut pruefen"; neue Zeile "Vollstaendige Inhaltsrichtlinie (Content Security Policy)" = **offen** mit Grund; "Auf einen Blick" (Bullet Pruefung von aussen) und Verlaufseintrag "Schutz-Kopfzeilen in der Anwendung". CHANGELOG: ein "Sicherheit:"-Bullet unter "Geaendert". Entwicklungsanleitung: Absatz "Schutz-Kopfzeilen der Weboberflaeche" unter "Konventionen und Fallstricke" (inkl. Warnung: clipboard-write nie sperren; `noopener` beibehalten).
### Abweichungen (Task 5)
1. **[Rule 3] Dokumentation getrennt committet:** Die Doku von Teil B und die des Zusatzauftrags liegen in denselben Dateien; fuer zwei saubere Commits wurden die Zusatz-Hunks fuer den ersten Commit voruebergehend herausgenommen und danach wiederhergestellt (kein Inhaltsverlust, beide Commits einzeln pruefbar).
2. Teil A: `biome check --write` sortierte die Imports in `crypto.service.ts` um (Biome-Regel `organizeImports`), und `biome format` brach alte Zeilen im Spec um. Verhalten unveraendert.
3. Die CHANGELOG-Aenderung kam nach dem ersten Bau; deshalb wurde der Stack nach dem letzten Commit nochmals mit `--build` neu gebaut (Voraussetzung von Task 6).
### Hinweise fuer Task 6
- Stack laeuft gebaut aus HEAD `fceec19` (`docker compose up -d --build --wait api web` nach dem letzten Commit, alle healthy). Lokale Abbilder: `tessera-ctl-api:latest` 1,12 GB, `tessera-ctl-web:latest` 359 MB; fuer den Abschlusslauf `SCAN_IMAGES="tessera-ctl-api:latest tessera-ctl-web:latest"` verwenden. Erwartung der Skriptzaehlung (OS + Node, nicht `--scanners vuln`-identisch, kann abweichen): api ~C0 H6, web ~H3.
- **Protokoll noch zu schliessen in Task 6:** "Stand nach der Behebung" mit Abschlusszahlen; die Tabelle "Erste vollstaendige Pruefung" behaelt die Rohwerte. Alle Zeilen "Einordnung der Befunde" haben jetzt einen Status; "offen" bleiben xlsx, node-forge, glib, nodemailer 10, sharp/deepmerge-ts, Gesundheitspruefung web, Vollstaendige Inhaltsrichtlinie, HTTPS/Proxy, Pnpm-Arbeitsbereichseinstellungen u. a.; die drei neuen Statuswoerter "behoben in der Anwendung, beim oeffentlichen Lauf erneut pruefen" enthalten das Wort "behoben" und passen auf die Task-6-Zeilenpruefung (Status + letzte Spalte nicht leer). Task-6-Gate: `grep -qx "semgrep-regel gcm-no-tag-length=0"`: die Regel sollte jetzt nichts mehr finden.
- Der Beweisdateiname `checks/task5-image.txt` enthaelt die Zeile `ergebnis umgesetzt`.
- Platte: 55 % belegt (78 GB frei) nach den Arbeiten; `docker image prune -f` hat nichts mehr zu entfernen.
- CLAUDE.md-Tabelle (D-26) unveraendert; zusaetzlich hinkt jetzt nichts Neues nach.
- Orchestrator-Hinweis: der naechste ZAP-Lauf (oeffentliche Adresse, nach dem naechsten alpha-Pull) sollte die Zeilen der Kopfzeilen auf "behoben" setzen oder pruefen, ob der Proxy sie doppelt sendet; die Anwendung sendet sie jetzt selbst.
## Task 6: Abschlusslauf, Protokoll-Abschluss, CLAUDE.md, Aufraeumen (erledigt)
**Commits (nicht gepusht):**
- `bc7fffe` docs(quick-261009-p0m): Technology-Stack-Zeilen auf den Stand der Lockdatei gebracht (CLAUDE.md, Zusatzauftrag, ueberschreibt D-26)
- `b085889` docs(quick-261009-p0m): Sicherheitsprotokoll mit Stand nach der Behebung
Nicht eingecheckt (Vorgabe): `checks/final-run.txt`, `baseline/SUMMARY.txt` (Block "nach Behebung"), diese Datei. Gate-Sentinel `task6 ok` gedruckt (Kette aus dem Plan wortgetreu).
### Abschlusslauf (`checks/final-run.txt`)
Frischer Klon (`git clone --no-local`) von HEAD `fceec19`, Container `gitea/runner-images:ubuntu-latest` (Netz `gitea`, Docker-Socket, frisches Werkzeug-Volume, `GITHUB_REF=refs/heads/main`, kein Kanarienvogel), Skript `all`: **rc=0**.
```
gitleaks findings=0
pnpm-audit-prod critical=0 high=9 moderate=3 low=0
osv-scanner packages=20
semgrep findings=33 errors=4
trivy-fs critical=0 high=9 medium=4 low=0 misconfig=2 secrets=0
trivy-image-api critical=0 high=6 medium=4 low=0
trivy-image-web critical=0 high=3 medium=1 low=0
semgrep-regel gcm-no-tag-length=0
```
- Die Zeilen der Abbilder heissen `trivy-image-api`/`-web`, weil das Skript den Namen aus dem Abbildnamen ableitet. Mit den lokalen Compose-Namen (`tessera-ctl-api:latest`) hiessen sie `trivy-image-tessera-ctl-api` (erster Versuch, gleiche Zahlen) und haetten das Plan-Gate nicht getroffen. Loesung ohne Skriptaenderung: der gesamte Lauf wurde wiederholt mit den lokalen Abbildern kurz als `api:p0m-final`/`web:p0m-final` getaggt (kein Registry-Name; Tags danach entfernt). Die Zeilen der Datei stammen aus diesem zweiten Lauf; er war der erste Lauf mit wiederverwendetem Werkzeug-Volume (`semgrep ... bereit (vorhanden)`), also ein lokaler Beleg fuer Annahme A2.
- Reste der Abbild-Funde (per Trivy am lokalen Abbild nachgemessen): api = deepmerge-ts, node-forge, nodemailer, xlsx (je HIGH, alle ohne Korrektur in der Versionslinie); web = nur sharp.
- osv 20 = 8 npm (xlsx, node-forge, nodemailer, sharp, deepmerge-ts, vitest 3.2.6, @vitest/mocker 3.2.6, tinypool) + 12 crates (glib, anyhow, event-listener, h2, quick-xml x2, proc-macro-error, unic-* x5).
- Semgrep Rest: 14 mutable Aktions-Tags (+1 curl|sh), 9 non-literal-regexp, 4 TLS-Umgehungen (bewusst), 1 open-redirect, 1 prototype-pollution-loop, 3 pnpm-Haertungsschluessel; die GCM-Regel ist weg.
### Protokoll-Abschluss
- Neuer Abschnitt `## Stand nach der Behebung` (Vorher/Nachher-Tabelle je Werkzeug, Liste des Offenen in Alltagssprache) und Verlaufseintrag `### 2026-10-09 — Stand nach der Behebung` (Kurzfassung mit Verweis; Tabelle steht aus Wartungsgruenden nur im Abschnitt). "Auf einen Blick" (Bausteine, automatische Pruefung, Was noch zu tun ist) auf den Endstand gebracht.
- "Einordnung der Befunde": 33 Zeilen, alle mit Stand und Begruendung. Neu: Zeile "Weitere Hinweise zu Bausteinen der Desktop-App" (11 Rust-Bausteine, offen; Patch-Fassungen fuer anyhow 1.0.103, event-listener 5.4.2, h2 0.4.16 existieren) und Zeile "Testwerkzeuge vitest 3 / tinypool" (offen). Pipeline-Zeile auf 14 mutable Tags aktualisiert, GCM-Zeile um "Semgrep meldet nicht mehr" ergaenzt.
- Wortregeln geprueft: kein "Lizenz", "Mandant", keine Du-Form, keine IP-Adressen; Link-Pruefer 12 Links 0 kaputt; gitleaks auf Protokoll und volle Historie (1377 Commits) 0 Funde.
### CLAUDE.md (Zusatzauftrag, ueberschreibt D-26)
Zeilen angepasst nach `pnpm-lock.yaml` (`importers:`): Next.js 15.5.27, NestJS 11.2.7, Vitest (apps/web) 4.1.11, dazu der Satz unter "Recommended But Not Adopted" (Next 15.5.27). Express 5.2.1, Prisma 6.19.3, React 19.2.7, Tailwind 4.3.1, TypeScript 5.9.3, Biome 2.5.0, Turborepo 2.9.18, Vitest (apps/api) 3.2.6, next-intl, Zustand und die uebrigen Zeilen stimmen unveraendert. Kopfhinweis ergaenzt: Stand 2026-09-09; die drei Zeilen am 2026-10-09 gegen die Lockdatei neu geprueft (Docker-Zeilen bleiben bewusst mit ihrem Messdatum). nodemailer und undici stehen nicht in der Tabelle.
### Aufraeumen
Entfernt (Name fuer Name): `semgrep/semgrep:1.180.0`, `aquasec/trivy:0.75.0`, `ghcr.io/aquasecurity/trivy:0.75.0`, `ghcr.io/google/osv-scanner:v2.6.0`, `ghcr.io/gitleaks/gitleaks:latest` und `:v8.30.1`, `curlimages/curl:latest`; `docker image prune -f` hatte danach nichts zu entfernen (nie `-a`). Behalten: ZAP-Abbild (Digest geprueft), `gitea/runner-images:ubuntu-latest`. Wegwerf-Klon, Werkzeug-Volume `p0m-final-tools` und die Zwischentags geloescht, root-eigene Berichte per Container entfernt; in `security-reports/` bleibt nur der ZAP-Ordner von alpha. **Platte: 54 % belegt (80 GB frei)**, weit unter 85 %.
### Abweichungen (Task 6)
1. **Abbildnamen im Abschlusslauf:** siehe oben, Lauf mit `api:p0m-final`/`web:p0m-final` statt `tessera-ctl-*:latest` (Plan-Gate verlangt `trivy-image-api`/`-web`; kein Skripteingriff noetig).
2. **Zusatz:** CLAUDE.md-Aktualisierung (Orchestrator-Auftrag) als eigener Commit `bc7fffe`.
3. **Protokoll:** Die Vorher/Nachher-Tabelle steht im Abschnitt "Stand nach der Behebung"; der Verlaufseintrag mit dem vom Plan verlangten Titel ist eine Kurzfassung mit Verweis (kein doppelt gepflegtes Zahlenwerk).
4. Zwei Zeilen in "Einordnung der Befunde" ergaenzt (Desktop-Hinweise, Testwerkzeuge): sie fehlten bisher, obwohl osv-scanner sie zaehlt (Plan: jede Zahl braucht Stand und Grund).
## Gesamtergebnis: vorher / nachher
| Werkzeug | Grundlinie (roh) | nach Ausnahmelisten (Task 1) | nach Behebung (Task 6) |
|---|---|---|---|
| gitleaks (Historie) | 20 (0 echt) | 0 | 0 |
| pnpm audit --prod | C5 H73 M68 L5 (151) | unveraendert | **C0 H9 M3 L0 (12)** |
| pnpm audit (alle) | C7 H85 M74 L5 (171) | -- | C2 H9 M5 L0 (16) (Task 4) |
| osv-scanner | 43 (nur pnpm-lock) | 56 (mit Cargo.lock) | 20 |
| Semgrep | 58 / 59 Scanfehler | 34 (ERROR 2) / 4 | 33 (ERROR 1) / 4; GCM-Regel 0 |
| Trivy fs | C5 H73 M68 L5, secrets 7, misconfig 2 | C5 H73 M70 L5, secrets 0 | C0 H9 M4 L0, secrets 0, misconfig 2 |
| Abbild api | C6 H116 M98 L7, 1,66 GB | C6 H116 M99 L7 | **C0 H6 M4 L0, 1,12 GB** |
| Abbild web | C2 H19 M21 L1, 359 MB | C2 H19 M22 L1 | **C0 H3 M1 L0, 359 MB** |
| ZAP alpha (direkt, ohne Proxy) | -- | -- | 0 / 2 / 6 / 3 (Task 3, vor den Kopfzeilen; nicht neu gemessen) |
## Offene Punkte (alle im Protokoll mit Grund)
1. **xlsx 0.18.5, node-forge 1.4.0:** keine bereinigte Fassung (Ersatz von xlsx waere ein eigener Auftrag).
2. **nodemailer (2 H, 3 M), sharp (3 H), deepmerge-ts (1 H):** Korrektur nur mit neuer Haupt-/Null-Minor-Version (nodemailer 10 ist die groesste Restposition).
3. **vitest/@vitest/mocker 3.2.6, tinypool 1.1.1 (API-Tests):** nur Entwicklung, Korrektur braucht vitest 4 in apps/api (nicht im Abbild).
4. **Desktop:** glib 0.18.5 plus 11 weitere Rust-Hinweise; **anyhow 1.0.103, event-listener 5.4.2, h2 0.4.16 sind Patch-Updates in der Versionslinie** (`cargo update -p ... --precise`) und koennten als kleiner Folgeauftrag eingespielt werden (waere ein Cargo.lock-Commit).
5. **Web-Abbild ohne Gesundheitspruefung** (weder Dockerfile noch Compose).
6. **Vollstaendige Inhaltsrichtlinie (CSP)**: bewusst nicht gesetzt, eigener Auftrag.
7. **Pipeline-Haertung:** 14 bewegliche Aktions-Tags, ein curl|sh, drei pnpm-Arbeitsbereichsschluessel (niedrig).
8. **Ausstehender oeffentlicher ZAP-Lauf** (siehe unten).
9. Nicht gebaut: woechentlicher geplanter Lauf der Pruefung (Forschung: jetzt nicht noetig); Anheben der Aktions-Tags.
## Ausstehender oeffentlicher ZAP-Lauf
Der erste Lauf ging direkt gegen die Anwendung auf alpha, weil die oeffentliche Adresse vom Dev-Host nicht erreichbar war (Zeitueberschreitung Port 443). Nach dem naechsten alpha-Pull (mit den Kopfzeilen aus `fceec19`): `sh .gitea/scripts/zap-baseline.sh` (Standardziel = oeffentliche Adresse; falls Basic Auth 401 liefert: `ZAP_BASIC_AUTH_FILE` mit `benutzer:passwort` ausserhalb des Repos). Danach im Protokoll: neue Zeile in "Pruefung von aussen gegen alpha", Verlaufseintrag, und die drei Zeilen "behoben in der Anwendung, beim oeffentlichen Lauf erneut pruefen" sowie HTTPS/Proxy auf ihren Endstand setzen.
## Checkliste erster CI-Lauf nach dem Push (Nutzer/Orchestrator)
- [ ] Job `security` erscheint **nach `publish`** (nur main und Tags `v*`), publish und Freigaben unveraendert.
- [ ] Jobfarbe: gelb/gruen trotz Funden (Annahme A3: `continue-on-error` auf Job-Ebene), kein anderer Job haengt daran.
- [ ] Im Lauf-Protokoll stehen alle `SECURITY-SUMMARY`-Zeilen (gitleaks, pnpm-audit-prod, osv-scanner, semgrep, trivy-fs, trivy-image-api, trivy-image-web, `fertig`); Zahlen etwa wie oben (Abbild-Zahlen mit Registry-Abbildern von publish).
- [ ] Artefakt `sicherheitsberichte` herunterladbar? (Annahme A4, `upload-artifact@v3`; best effort, Ausfall ist kein Fehler.)
- [ ] Zweiter Lauf: Werkzeuge aus dem `act-toolcache`-Volume wiederverwendet (Zeile "bereit (vorhanden)"; Annahme A2; lokal mit Volume bestaetigt).
- [ ] Dauer des Jobs (Limit 30 min) und Plattenstand des Runners.
- [ ] Der Desktop-Job baut die Pakete neu, weil `Cargo.lock` sich geaendert hat (erwartet).
## Hinweise an den Orchestrator
- CHANGELOG: "Sicherheit:"-Bullets liegen innerhalb der bestehenden Gruppen Neu/Geaendert/Behoben (D-21: die "Was ist neu"-Anzeige liest nur diese drei).
- Entfernte Pakete: `@nestjs-modules/mailer`, `ews-javascript-api` (D-22); keine neuen Paketnamen, 232 entfallen.
- Overrides (15) und Gruende stehen in Task 4; Prisma 6.19.3, Next 15, NestJS 11 unveraendert.
- Keine Modulversion geaendert, nichts gepusht, nichts deployed.
- CLAUDE.md-Tabelle ist jetzt aktuell (siehe oben).
## Self-Check: PASSED
Gefunden: `docs/sicherheitsprotokoll.md`, `checks/final-run.txt`, `baseline/SUMMARY.txt` (Block "nach Behebung"), Commits `bc7fffe` und `b085889` sind Vorfahren von HEAD, `git status` ausserhalb von `.planning/` sauber; Gate-Sentinel `task6 ok`.
## Review fixes
Review findings WR-01, WR-02, WR-04, WR-05 and IN-01 to IN-04 fixed; commits `bf632d9` (scripts, Dockerfiles, next.config comment) and `8a1218a` (docs). Not pushed. Details per finding: "Fix status" in `261009-p0m-REVIEW.md`.
- **Image start proof** is now versioned: `bash .gitea/scripts/image-start-check.sh [Abbild]` (replaces `checks/fresh-db-start.sh`, which stays here as history). Rebuilt stack (`docker compose up -d --build api web`): api healthy; proof printed `migrationen=63`, `frische-datenbank ok`.
- **Build fails when it must:** broken `schema.prisma` in a throwaway Dockerfile fails at the explicit `prisma generate` step (rc 1); a runner stage that keeps npm fails at the absence check. Both throwaway images removed; real Dockerfiles unchanged by the experiment.
- **security-scan.sh** in `gitea/runner-images:ubuntu-latest` (fresh throwaway clone, socket, fresh tool volume, `GITHUB_REF=refs/heads/main`, both local images): rc 0, 63 s, per-tool lines (gitleaks 0, pnpm-audit-prod critical 0 high 9, osv 20, semgrep 33/4, trivy-fs secrets 0, images api C0 H6, web C0 H3). Also: shallow clone -> `unvollstaendig`; forced limits -> `zeitgrenze`, forced tiny budget -> `gesamtbudget`; offline run rc 0 with 12 skipped lines (Task 1 gate needs >= 7); no semgrep container left behind.
- **ZAP:** local passive proof (POST=0, header only to the target, never to a second host or a prefix-port lookalike); special-character password works; handover path for uid != 1000 exercised.
- **Gates:** `ci.yml` node check from PLAN Task 1 prints `ci.yml ok` (file unchanged); `sh -n` on all `.gitea/scripts/*.sh`, `bash -n` on the new script, shellcheck (via container image, removed afterwards) shows only SC2329 info for trap handlers; link-check 12 links, 0 broken; protocol table gate 34 rows ok; gitleaks `dir` on the changed docs/scripts clean.
- **Environment notes:** semgrep image (1.5 GB) now stays cached on the dev host and will on the runner host after the first CI run (needed for the digest-pinned scan; documented in `docs/ci-cd-setup.md`). Disk 57 % after cleanup (`docker image prune -f` only). No shellcheck installed on the host; it was run from `koalaman/shellcheck:stable`.
- **Docs updated:** `docs/sicherheitsprotokoll.md` (Verlauf entry "Codeprüfung des Sicherheitsauftrags", table row, sums 9 reviews / 98 findings / 84 fixed, new open row for WR-03), `docs/ci-cd-setup.md`, `docs/anleitung-entwicklung.md`, `docs/anleitung-betrieb.md`.
### Checklist after the first real CI run (WR-03)
- [ ] Job `security` ends green or neutral, not red (red would mean the Gitea version ignores job-level `continue-on-error`).
- [ ] `timeout-minutes: 30` is honoured; first run duration (semgrep image pull of about 1.5 GB and Trivy DB included) stays below 25 minutes; the `SECURITY-SUMMARY dauer=...` line shows it.
- [ ] The next push's `quality`/`test` is not noticeably delayed by the single runner; if it is, move the scan to a nightly schedule plus tags `v*`.
- [ ] `SECURITY-SUMMARY semgrep findings=... errors=...` appears (container path works inside the real job container with the host socket) and artifact `sicherheitsberichte` is best effort.
@@ -0,0 +1,2 @@
# Rohdaten der Grundlinie (6,7 MB) bleiben ausserhalb von Git (D-12); nur SUMMARY.txt wird eingecheckt.
*.json
@@ -0,0 +1,36 @@
Baseline 2026-10-09, HEAD cc713b5 (git archive HEAD copy for source scans; gitleaks read .git read-only)
Tools: gitleaks v8.30.1, trivy 0.75.0, osv-scanner v2.6.0, semgrep 1.180.0 (p/default p/typescript p/nodejs p/secrets p/dockerfile), pnpm audit (pnpm 9.15 registry endpoint)
gitleaks-history.json : 20 findings / 1367 commits (16 private-key [test fixtures + doc snippets], 3 generic-api-key [i18n labels, doc table], 1 curl-auth-user [test-Nextcloud example]) -> 0 real secrets
pnpm-audit-all.json : 171 vulns (critical 7, high 85, moderate 74, low 5), 151 advisories, 1268 deps
pnpm-audit-prod.json : 151 vulns (critical 5, high 73, moderate 68, low 5)
osv-scanner.json : 43 vulnerable package@version of 1262 (pnpm-lock.yaml)
trivy-fs.json : pnpm-lock 151 (C5 H73 M68 L5), Cargo.lock 2 MEDIUM, misconfig 2 LOW (no HEALTHCHECK api+web Dockerfile), secrets 7 HIGH = test key fixtures
trivy-image-api.json : api:beta Node.js C6 H116 M98 L7 ; alpine os-pkgs M1 ; secrets 0
trivy-image-web.json : web:beta Node.js C2 H19 M21 L1 ; alpine os-pkgs M1 ; secrets 0
semgrep.json : 58 findings (ERROR 9, WARNING 46, MEDIUM 3); 7 ERROR = test key fixtures, 59 scan errors (43 PartialParsing, 16 Timeout)
nach Ausnahmelisten (2026-10-09, Task 1)
Quelle: voller Lauf von .gitea/scripts/security-scan.sh in gitea/runner-images:ubuntu-latest (Netz gitea, Docker-Daemon des Hosts, GITHUB_REF=refs/heads/main), HEAD 3d00be8 plus ein Wegwerf-Klon mit einem Kanarienvogel-Token (die Treffer des Kanarienvogels sind unten abgezogen). Rohdaten (JSON) bleiben ausserhalb von Git (baseline/.gitignore).
gitleaks : 0 Treffer ueber die gesamte Historie (vorher 20; alle 20 waren geprueft harmlos, jetzt einzeln freigegeben in .gitleaks.toml); Kanarienvogel wird gefunden (1 Treffer, github-pat)
pnpm audit --prod : critical 5, high 73, moderate 68, low 5 (unveraendert zur Grundlinie)
osv-scanner : 56 verwundbare package@version (pnpm-lock.yaml + Cargo.lock; die Grundlinie zaehlte nur pnpm-lock.yaml: 43)
semgrep : 34 Treffer (ERROR 2, WARNING 29, MEDIUM 3), 4 Scanfehler (alle PartialParsing: ci.yml, beide Dockerfiles, 0 Zeitueberschreitungen); vorher 58 Treffer / 59 Fehler. Der Kanarienvogel (detected-github-token) ist abgezogen. .semgrepignore entfernt Testschluessel, Tests, Testdaten und Planungsnotizen
trivy fs : critical 5, high 73, medium 70, low 5, misconfig 2 (kein HEALTHCHECK), secrets 0 (vorher 7 = Testschluessel); Kanarienvogel wird gefunden (secrets=1, github-pat)
trivy image api:beta : critical 6, high 116, medium 99, low 7 (Node-Pakete plus Alpine), secrets 0
trivy image web:beta : critical 2, high 19, medium 22, low 1 (Node-Pakete plus Alpine), secrets 0
Nachweis: checks/task1-runner-full.txt (nur Zeilen SECURITY-SUMMARY und rc=0, mit Kanarienvogel); Offline-Lauf im Runner-Abbild: Exit 0, alle Werkzeuge uebersprungen.
ZAP-Grundpruefung (2026-10-09, Task 3)
zap (alpha, direkt gegen die Anwendung, ohne Proxy): hoch 0, mittel 2, niedrig 6, info 3 (11 Meldungsarten, 28 URLs, passiv, 0 POST). Mittel: fehlende Content-Security-Policy, fehlender Schutz gegen Einrahmen (Clickjacking). Niedrig: fehlende Kopfzeilen X-Content-Type-Options, Permissions-Policy, Cross-Origin-Embedder/Opener/Resource-Policy; Kopfzeile X-Powered-By. Lauf ueber die oeffentliche Adresse steht aus (vom Pruefrechner nicht erreichbar). Lokaler Beweis: checks/task3-zap-local.txt (POST=0 in beiden Laeufen). Rohberichte bleiben in security-reports/ (nicht in Git).
nach Behebung (2026-10-09, Task 6)
Quelle: Abschlusslauf von .gitea/scripts/security-scan.sh in gitea/runner-images:ubuntu-latest (Netz gitea, Docker-Daemon des Hosts, GITHUB_REF=refs/heads/main), frischer Klon von HEAD fceec19, lokal gebaute Abbilder; Exit 0. Nachweis: checks/final-run.txt.
gitleaks : 0 Treffer ueber die gesamte Historie
pnpm audit --prod : critical 0, high 9, moderate 3, low 0 (vorher C5 H73 M68 L5)
osv-scanner : 20 verwundbare package@version (8 npm: xlsx, node-forge, nodemailer, sharp, deepmerge-ts, vitest, @vitest/mocker, tinypool; 12 crates: glib, anyhow, event-listener, h2, quick-xml x2, proc-macro-error, unic-* x5); vorher 56
semgrep : 33 Treffer (ERROR 1, WARNING 29, MEDIUM 3), 4 Scanfehler (PartialParsing); Regel gcm-no-tag-length: 0 (vorher 1)
trivy fs : critical 0, high 9, medium 4, low 0, misconfig 2 (kein HEALTHCHECK), secrets 0
trivy image api : critical 0, high 6, medium 4, low 0 (nodemailer, xlsx, node-forge, deepmerge-ts); Groesse 1,12 GB (vorher 1,66 GB)
trivy image web : critical 0, high 3, medium 1, low 0 (sharp); Groesse 359 MB
zap : nicht neu gemessen; Lauf ueber die oeffentliche Adresse steht aus
Werkzeugordner-Wiederverwendung (Volume mit Werkzeugen): zweiter Lauf meldet "semgrep ... bereit (vorhanden)" statt neuer Installation.
@@ -0,0 +1,11 @@
# Abschlusslauf 2026-10-09 auf HEAD fceec19 (frischer Klon, Runner-Abbild gitea/runner-images:ubuntu-latest, Netz gitea, GITHUB_REF=refs/heads/main, lokale Abbilder api:p0m-final/web:p0m-final = tessera-ctl-api/web:latest)
SECURITY-SUMMARY gitleaks findings=0
SECURITY-SUMMARY pnpm-audit-prod critical=0 high=9 moderate=3 low=0
SECURITY-SUMMARY osv-scanner packages=20
SECURITY-SUMMARY semgrep findings=33 errors=4
SECURITY-SUMMARY trivy-fs critical=0 high=9 medium=4 low=0 misconfig=2 secrets=0
SECURITY-SUMMARY trivy-image-api critical=0 high=6 medium=4 low=0
SECURITY-SUMMARY trivy-image-web critical=0 high=3 medium=1 low=0
SECURITY-SUMMARY fertig (nur Bericht, Exit 0)
rc=0
semgrep-regel gcm-no-tag-length=0
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# quick-261009-p0m, Task 5: Beweis, dass das Server-Abbild ohne Entwicklungswerkzeuge
# gegen eine FRISCHE Datenbank alle Migrationen anwendet und die API startet.
# Nur Testwerte; alles Angelegte wird per Falle entfernt. Druckt bei Erfolg
# "frische-datenbank ok".
set -u
IMAGE="${1:-tessera-ctl-api:latest}"
ID="p0m-fresh-$$"
NET="$ID-net"
DB="$ID-db"
API="$ID-api"
LOG="$(mktemp)"
cleanup() {
docker rm -f "$API" "$DB" >/dev/null 2>&1
docker network rm "$NET" >/dev/null 2>&1
rm -f "$LOG"
}
trap cleanup EXIT INT TERM HUP PIPE
fail() {
echo "FEHLER: $*" >&2
echo "--- Protokoll der API (letzte 40 Zeilen) ---" >&2
docker logs "$API" 2>&1 | tail -40 >&2
exit 1
}
docker network create "$NET" >/dev/null || { echo "FEHLER: Netz nicht anlegbar" >&2; exit 1; }
docker run -d --name "$DB" --network "$NET" \
-e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera_test -e POSTGRES_DB=tessera \
postgres:16-alpine >/dev/null || { echo "FEHLER: Datenbank startet nicht" >&2; exit 1; }
i=0
until docker exec "$DB" pg_isready -U tessera -d tessera >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 60 ] && { echo "FEHLER: Datenbank wird nicht bereit" >&2; exit 1; }
sleep 1
done
# pg_isready meldet schon waehrend der Einrichtung "bereit"; kurz warten, dann
# eine echte Abfrage (die Einrichtung startet den Server einmal neu).
sleep 3
i=0
until docker exec "$DB" psql -U tessera -d tessera -tAc 'select 1' >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 30 ] && { echo "FEHLER: Datenbank antwortet nicht" >&2; exit 1; }
sleep 1
done
KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
docker run -d --name "$API" --network "$NET" \
-e "DATABASE_URL=postgresql://tessera:tessera_test@$DB:5432/tessera" \
-e "TESSERA_ENCRYPTION_KEY=$KEY" \
-e "JWT_SECRET=fresh-db-test-jwt-secret" \
-e TESSERA_ADMIN_USER=admin -e TESSERA_ADMIN_EMAIL=admin@tessera.local \
-e TESSERA_ADMIN_PASSWORD=fresh-db-test-pw -e TESSERA_FORCE_CHANGE=false \
"$IMAGE" >/dev/null || { echo "FEHLER: API startet nicht" >&2; exit 1; }
i=0
while :; do
docker logs "$API" >"$LOG" 2>&1
grep -q "Tessera API running" "$LOG" && break
[ "$(docker inspect -f '{{.State.Running}}' "$API" 2>/dev/null)" = "true" ] || fail "Container ist beendet, bevor die Startzeile kam"
i=$((i + 1))
[ "$i" -gt 120 ] && fail "keine Startzeile nach 120 Sekunden"
sleep 1
done
grep -qi "successfully applied" "$LOG" || fail "Prisma meldet nicht, dass die Migrationen angewendet wurden"
N="$(docker exec "$DB" psql -U tessera -d tessera -tAc "select count(*) from _prisma_migrations where finished_at is not null")"
[ "${N:-0}" -ge 1 ] || fail "keine abgeschlossene Migration in der Datenbank"
echo "migrationen=$N"
echo "frische-datenbank ok"
@@ -0,0 +1,104 @@
"""Linkpruefung fuer das Sicherheitsprotokoll und seine Verweise (quick-261009-p0m).
Aufruf vom Hauptordner des Repositorys: python3 -I <Pfad>/link-check.py
Geprueft werden in docs/sicherheitsprotokoll.md, docs/README.md,
docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md und docs/ci-cd-setup.md
alle relativen Markdown-Links, die
- im Sicherheitsprotokoll stehen, oder
- auf sicherheitsprotokoll.md zeigen, oder
- der Anker #sicherheitsprüfungen sind.
Das Linkziel (Datei) muss existieren, und ein Anker muss zu einer Ueberschrift des
Ziels passen (GitHub-Regel: Kleinbuchstaben, alles ausser Wortzeichen, Bindestrich
und Leerzeichen entfernen, Leerzeichen zu Bindestrichen). Exit 1 bei Fehlern.
"""
import os
import re
import sys
from urllib.parse import unquote
DOCS = [
"docs/sicherheitsprotokoll.md",
"docs/README.md",
"docs/anleitung-betrieb.md",
"docs/anleitung-entwicklung.md",
"docs/ci-cd-setup.md",
]
PROTOCOL = "docs/sicherheitsprotokoll.md"
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
def slug(heading):
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading) # Link -> Text
text = text.replace("`", "").replace("*", "")
text = text.strip().lower()
text = re.sub(r"[^\w\- ]", "", text)
return text.replace(" ", "-")
def anchors(path):
found = set()
in_fence = False
with open(path, encoding="utf-8") as handle:
for line in handle:
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
match = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line)
if match:
found.add(slug(match.group(2)))
return found
def main():
broken = []
checked = 0
cache = {}
for doc in DOCS:
if not os.path.exists(doc):
broken.append(f"{doc}: Datei fehlt")
continue
with open(doc, encoding="utf-8") as handle:
text = handle.read()
in_fence = False
for number, line in enumerate(text.splitlines(), 1):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
for target in LINK.findall(line):
if re.match(r"^[a-z][a-z0-9+.-]*:", target):
continue # http:, mailto: usw.
file_part, _, anchor = target.partition("#")
anchor = unquote(anchor)
relevant = (
doc == PROTOCOL
or file_part.endswith("sicherheitsprotokoll.md")
or anchor == "sicherheitsprüfungen"
)
if not relevant:
continue
checked += 1
dest = doc if file_part == "" else os.path.normpath(
os.path.join(os.path.dirname(doc), file_part)
)
if not os.path.exists(dest):
broken.append(f"{doc}:{number}: Ziel fehlt: {target}")
continue
if anchor:
if dest not in cache:
cache[dest] = anchors(dest)
if anchor not in cache[dest]:
broken.append(f"{doc}:{number}: Anker fehlt in {dest}: #{anchor}")
for line in broken:
print(line)
print(f"{checked} Links geprueft, {len(broken)} kaputt")
return 1 if broken else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Mail-Rauchtest (quick-261009-p0m, Aufgabe 4): beweist, dass die API nach dem Austausch von
# nodemailer und dem Entfernen des Mailer-Pakets weiterhin Mails an den lokalen Mailhog zustellt.
# Nur Testwerte (lokaler Stack, admin/admin123), liest keine .env-Dateien.
set -euo pipefail
LIB=".planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh"
cd "$(git rev-parse --show-toplevel)"
# shellcheck source=/dev/null
source "$LIB"
MAILHOG=${MAILHOG:-http://localhost:8025}
JAR="$E2E_TMP/mail-admin.jar"
mail_total() {
curl -sf "$MAILHOG/api/v2/messages?limit=1" \
| python3 -I -c 'import json,sys; print(json.load(sys.stdin)["total"])'
}
e2e_login "$JAR"
# /auth/me liefert keine E-Mail-Adresse; die Adresse des angemeldeten Admins steht in /users.
code=$(e2e_status "$JAR" GET "$API/users" "" "$E2E_TMP/users.out")
e2e_expect 200 "$code" "users"
email=$(python3 -I -c 'import json,sys
d=json.load(open(sys.argv[1]))
d=d if isinstance(d,list) else d.get("items",[])
print(next((u.get("email") or "" for u in d if u.get("username")=="admin"),""))' "$E2E_TMP/users.out")
[ -n "$email" ] || e2e_fail "Admin hat keine E-Mail-Adresse"
before=$(mail_total)
code=$(e2e_status "$JAR" POST "$API/auth/request-reset" "{\"email\":\"$email\"}")
e2e_expect 200 "$code" "request-reset"
for _ in $(seq 1 20); do
now=$(mail_total)
if [ "$now" -gt "$before" ]; then
echo "mail ok"
exit 0
fi
sleep 1
done
e2e_fail "keine neue Mail in Mailhog nach 20 Sekunden (vorher $before)"
@@ -0,0 +1,9 @@
SECURITY-SUMMARY gitleaks findings=1
SECURITY-SUMMARY pnpm-audit-prod critical=5 high=73 moderate=68 low=5
SECURITY-SUMMARY osv-scanner packages=56
SECURITY-SUMMARY semgrep findings=35 errors=4
SECURITY-SUMMARY trivy-fs critical=5 high=73 medium=70 low=5 misconfig=2 secrets=1
SECURITY-SUMMARY trivy-image-api critical=6 high=116 medium=99 low=7
SECURITY-SUMMARY trivy-image-web critical=2 high=19 medium=22 low=1
SECURITY-SUMMARY fertig (nur Bericht, Exit 0)
rc=0
@@ -0,0 +1,2 @@
lauf-ohne-anmeldung POST=0 GET=8
lauf-mit-anmeldung POST=0 auth_ja=8 auth_nein=1
@@ -0,0 +1,5 @@
vorher prod critical=5 high=73 moderate=68 low=5
vorher alle critical=7 high=85 moderate=74 low=5
nachher prod critical=0 high=9 moderate=3 low=0
nachher alle critical=2 high=9 moderate=5 low=0
neue Paketnamen im Lockfile: 0 (232 Namen entfallen, 0 hinzu)
@@ -0,0 +1,8 @@
# Task 5 Teil B: Laufzeitabbilder (Zahlen und Statuswoerter, keine Zugangsdaten)
# Zaehlung: trivy image --scanners vuln (Betriebssystem- und Node-Pakete), Host-Abbild tessera-ctl-{api,web}:latest
paketverwaltungen-im-node-abbild: /usr/local/lib/node_modules/{npm,corepack}, /opt/yarn-v1.22.22, /usr/local/bin/{npm,npx,corepack,yarn,yarnpkg}
vorher api groesse=1.59GB critical=3 high=45 medium=41 low=2
vorher web groesse=359MB critical=0 high=11 medium=13 low=1
nachher api groesse=1.12GB critical=0 high=6 medium=4 low=0
nachher web groesse=359MB critical=0 high=3 medium=1 low=0
ergebnis umgesetzt
@@ -0,0 +1,18 @@
datum 2026-10-09 19:41 (Ortszeit Entwicklungsrechner)
ziel direkt gegen die Anwendung auf alpha (ohne vorgeschalteten Proxy); oeffentliche Adresse vom Pruefrechner nicht erreichbar (Zeitueberschreitung)
version v1.10.1-80-gd15a470 channel=beta commit=d15a470 (abgefragt ueber /api-proxy/health/version)
abbild ZAP 2.17.0, klassische Spinne 3 Minuten, 28 URLs, keine Formulare, kein POST
ZAP-Grundpruefung gegen http://192.168.13.12:3000 (nur passiv, keine Formulare, keine Angriffe)
ZAP-Lauf beendet (Protokoll: /home/vicolab/projects/tessera-ctl/security-reports/zap-alpha-intern-20261009/zap-lauf.log)
ZAP-SUMMARY ziel=192.168.13.12:3000 hoch=0 mittel=2 niedrig=6 info=3
ZAP-MELDUNG mittel Content Security Policy (CSP) Header Not Set
ZAP-MELDUNG mittel Missing Anti-clickjacking Header
ZAP-MELDUNG niedrig Cross-Origin-Embedder-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Cross-Origin-Opener-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Cross-Origin-Resource-Policy Header Missing or Invalid
ZAP-MELDUNG niedrig Permissions Policy Header Not Set
ZAP-MELDUNG niedrig Server Leaks Information via "X-Powered-By" HTTP Response Header Field(s)
ZAP-MELDUNG niedrig X-Content-Type-Options Header Missing
ZAP-MELDUNG info Content-Type Header Missing
ZAP-MELDUNG info Non-Storable Content
ZAP-MELDUNG info Storable and Cacheable Content
@@ -0,0 +1,59 @@
"""Kleiner Testserver fuer den lokalen Passivitaetsbeweis der ZAP-Grundpruefung.
Aufruf: python3 -I zap-testserver.py PORT LOGDATEI [require-auth]
/ verweist auf /login und /info, /login enthaelt ein POST-Formular mit Benutzer,
Passwort und Absende-Knopf. Jede Anfrage wird als "METHODE PFAD auth=ja|nein"
protokolliert. Mit require-auth beantwortet der Server jede Anfrage ohne
Authorization-Kopf mit 401. Nur Testwerte, keine echten Zugangsdaten.
"""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(sys.argv[1])
LOG = sys.argv[2]
REQUIRE_AUTH = len(sys.argv) > 3 and sys.argv[3] == "require-auth"
PAGES = {
"/": '<html><body><a href="/login">Anmelden</a> <a href="/info">Info</a></body></html>',
"/login": (
'<html><body><form method="POST" action="/login">'
'<input type="text" name="user"><input type="password" name="password">'
'<input type="submit" value="Anmelden"></form></body></html>'
),
"/info": '<html><body><p>Info</p><a href="/">zurueck</a></body></html>',
}
class Handler(BaseHTTPRequestHandler):
def _log(self):
auth = "ja" if self.headers.get("Authorization") else "nein"
with open(LOG, "a", encoding="utf-8") as fh:
fh.write("%s %s auth=%s\n" % (self.command, self.path, auth))
return auth == "ja"
def _answer(self):
has_auth = self._log()
if REQUIRE_AUTH and not has_auth:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="test"')
self.send_header("Content-Length", "0")
self.end_headers()
return
body = PAGES.get(self.path.split("?")[0], "<html><body>nicht gefunden</body></html>")
code = 200 if self.path.split("?")[0] in PAGES else 404
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = _answer
def log_message(self, *args):
pass
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()