Files
tessera-ctl/.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/261009-p0m-PLAN.md
T
schalli d62e6c2dbe
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
docs(quick-261009-p0m): Sicherheitsprotokoll und CI-Pruefungen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:07:46 +02:00

114 KiB
Raw Blame History

phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on quick_id description date files_modified autonomous requirements estimate must_haves
quick-261009-p0m 01 execute 1
261009-p0m 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 2026-10-09
.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
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
.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
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
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
.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/checks/final-run.txt
true
QUICK-261009-p0m
tokens raw_tokens tasks confidence
440000 440000 6 low
truths artifacts key_links
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)
path provides contains
.gitea/scripts/security-scan.sh 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 SECURITY-SUMMARY
path provides contains
.gitleaks.toml default gitleaks rules plus narrow allowlists for the verified false positives useDefault = true
path provides
.semgrepignore test files, fixtures and planning notes excluded from SAST
path provides contains
.gitea/workflows/ci.yml job security after publish, main and v* only, continue-on-error, no secrets, artifact v3 best effort security-scan.sh all
path provides contains
.gitea/scripts/zap-baseline.sh passive ZAP baseline against alpha with preflight, pinned image digest, optional Basic-Auth header from a file outside git, ZAP-SUMMARY line zap-baseline.py
path provides contains
.gitea/scripts/zap-hooks.py ZAP hook: spider without form processing and POST; optional Authorization replacer rule set_option_post_form
path provides contains
docs/sicherheitsprotokoll.md the living security protocol for owner and customers ## Einordnung der Befunde
path provides contains
apps/api/src/crypto/crypto.service.ts AES-256-GCM decrypt with enforced 16-byte authentication tag authTagLength
path provides
apps/api/Dockerfile runtime stage from node:24-alpine with production dependencies only and without package managers
path provides
.planning/quick/261009-p0m-sicherheitsprotokoll-und-ci-sicherheitsp/baseline/SUMMARY.txt small committed summary of baseline, after-allowlist, ZAP and final numbers (raw JSON stays out of git)
from to via pattern
.gitea/workflows/ci.yml job security .gitea/scripts/security-scan.sh single run step ending in || true, after needs: publish, if main or refs/tags/v security-scan.sh all || true
from to via pattern
.gitea/scripts/security-scan.sh gitleaks step .gitleaks.toml --config .gitleaks.toml --redact on the full history --config .gitleaks.toml
from to via pattern
.gitea/scripts/security-scan.sh image step images built by publish-images.sh in the host docker daemon GITHUB_REF → localhost:3002/schalli/tessera-ctl/{api,web}:beta or :live, trivy --image-src docker localhost:3002/schalli/tessera-ctl
from to via pattern
.gitea/scripts/zap-baseline.sh .gitea/scripts/zap-hooks.py --autooff --hook mounted read-only into the pinned ZAP container --hook=
from to via pattern
docs/anleitung-betrieb.md Kapitel 9 „Eine Version freigeben“ .gitea/scripts/zap-baseline.sh and docs/sicherheitsprotokoll.md release step: outside check against alpha, result into the protocol zap-baseline.sh
from to via pattern
docs/README.md docs/sicherheitsprotokoll.md Markdown link in the index ](sicherheitsprotokoll.md)
from to via pattern
apps/api/src/crypto/crypto.service.ts decrypt node:crypto createDecipheriv length check of the tag plus authTagLength 16 authTagLength
from to via pattern
apps/api/Dockerfile runner stage prod-deps stage (production install with generated Prisma client) COPY --from of node_modules and apps/api/node_modules --prod
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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.
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 .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 .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 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. 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). 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" <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> 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.

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 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 .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 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. 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 …“ (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). 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" <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> 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.

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 .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 .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 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. 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). 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" <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> 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. 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.

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 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 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 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). 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`). 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" 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 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. 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 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 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 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`). - 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 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`). 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" 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 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. 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 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 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) 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). 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`). 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" 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 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.

<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>
- Each task's `` 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, `
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

<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>
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 %.