Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
15 KiB
phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status
| phase | reviewed | depth | files_reviewed | files_reviewed_list | findings | status | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261009-p0m | 2026-10-09T21:00:00Z | standard | 24 |
|
|
issues_found |
Quick 261009-p0m: Code Review Report
Reviewed: 2026-10-09 Depth: standard Files Reviewed: 24 Status: issues_found
Summary
Reviewed git diff d15a470..HEAD (without pnpm-lock.yaml details and .planning). The lockfiles
were only skimmed for unexpected major bumps: there are none (Next 15.5.19 -> 15.5.27, NestJS
11.1.x -> 11.2.7, nodemailer 9.0.1 -> 9.1.1, undici 7.28.0 -> 7.30.0, vitest 4.1.9 -> 4.1.11,
rustls 0.23.41 -> .45, rustls-webpki .13 -> .15; overrides all stay inside their major).
No finding blocks a push. The checks asked for came out like this.
Verified OK:
- ci.yml. The diff only appends. The existing jobs are untouched. The
if:is identical to the one ondesktop.needs: publishis correct, and nothing depends onsecurity. There is nosecrets.in the job, andtimeout-minutes: 30is set. - security-scan.sh. It ends in
exit 0, the workflow step adds|| true, and every download is SHA256-checked (cached archive included) before use. Only counts and status words are logged. The tool config sits in$WORKand is removed by theEXITtrap. The script's own network calls go only to GitHub releases, ghcr.io (Trivy DB), semgrep.dev, osv.dev, npm and PyPI. It never contacts Tessera or alpha. The only registry it touches is the local Docker daemon, viadocker image inspect. - Crypto tag check. Every ciphertext in the repo comes from
CryptoService.encrypt, which has used Node's default 16-byte tag since the first commit (9ec6313,createCipherivwithout options). No SQL migration, seed or script writes ciphertext. The LDAP backfill migration only renames a column. Alldecryptcallers (LDAP, calendar/Exchange, SMTP, DKV, tender mailbox, Nextcloud app password, AutoDNS, Proxmox) therefore read 16-byte tags, so stored values on alpha/live will not fail. There is no legacy short-tag format. - Removed packages.
@nestjs-modules/mailer,ews-javascript-apiandhandlebarsappear only in comments. Every non-relative import inapps/api/srcis declared inapps/api/package.json. Under pnpm's strict layout the removal cannot break a hoisted import. - nodemailer 9.1 / undici 7.30. These are patch/minor bumps inside the major. Usage is limited to
createTransport/sendMail/Transporterandfetch/request/Agent. Nothing in the diff touches them. - Runtime image.
prismais independencies, soapps/api/node_modules/.bin/prismaexists formigrate-and-start.sh. Compose healthchecks usewget(busybox), not npm. No repo script runsnpm/npx/pnpminside the api or web container.docs/anleitung-betrieb.mdstates thatexec api pnpm/npmno longer works. - Web headers vs. embedding. The only iframes are the XFrame widget and custom modules (foreign
origins, not affected), plus the welcome-mail preview (
srcDoc,sandbox=""). There is no iframe of a Tessera page. The desktop app navigates its window to the server URL and does not frame it. Nothing usesgetUserMedia, geolocation,navigator.usborPaymentRequest. The onlywindow.opencall already passesnoopener,noreferrer, so COOPsame-origin-allow-popupsis safe. - ZAP. The hook turns off form processing and form POST, and there is no AJAX spider and no active
scan. The
ghcr.ioimage is pinned by digest.
Warnings
WR-01: Prisma client for the runtime image is generated only through a silently-failing postinstall, and nothing in CI starts the image
File: apps/api/Dockerfile:33-39, apps/api/package.json:13
Issue: The old Dockerfile ran an explicit RUN prisma generate, which fails the build on error. Now
the runtime client only comes from the postinstall of apps/api during pnpm install --prod
(prisma generate || true). If the engine download or the generate step fails, the error is swallowed.
The image builds, publish pushes it, and it dies at start-up (@prisma/client did not initialize yet).
It also breaks if a pnpm update stops running workspace-project lifecycle scripts. The proof that it works
(checks/fresh-db-start.sh, task5-image.txt) lives in untracked .planning/, so nothing repeats it
after this commit. The CI publish job builds the image but never starts it.
Fix:
RUN pnpm install --frozen-lockfile --prod --filter=@tessera/api...
# Hard-fail if the client was not generated (postinstall swallows errors with `|| true`)
RUN apps/api/node_modules/.bin/prisma generate --schema apps/api/prisma/schema.prisma \
&& find node_modules/.pnpm -path '*/.prisma/client/index.js' | grep -q .
Also commit fresh-db-start.sh (e.g. under .gitea/scripts/) and call it after the image build, or at
least keep it reachable from the release checklist in the Betriebsanleitung.
WR-02: Semgrep is installed from PyPI without hash pinning, in a job that has the host Docker socket
File: .gitea/scripts/security-scan.sh:254-270
Issue: gitleaks, Trivy and osv-scanner are SHA256-pinned. Semgrep is only version-pinned:
pipx install semgrep==1.180.0 resolves its transitive dependencies freely. A compromised PyPI
dependency would execute in a job container that mounts /var/run/docker.sock (see
docker-compose.ci.yml), which is root on the host. The install lands in the persistent
/opt/hostedtoolcache/tessera-security. On later runs only --version | grep -q "^1.180.0" is checked,
and that executes the cached binary. The plan and docs claim "pinned and checksum-verified scanners";
this one is not. "No secret in the job" limits what leaks, but not the host exposure.
Fix: Install from a hash-locked requirements file (pip install --require-hashes -r semgrep-1.180.0.txt
into a venv). Alternatively run Semgrep as a container pinned by digest. Do not trust the cached
semgrep beyond a version string.
WR-03: "Never fails and never delays the pipeline" is not guaranteed, and was not exercised on the real Gitea
File: .gitea/workflows/ci.yml:266-295
Issue:
- Job-level
continue-on-errorandtimeout-minutesare honored by GitHub, but Gitea Actions support is version-dependent. The SUMMARY says the first real CI run is still pending. The script itself cannot fail, so the remaining ways to turn the run red areactions/checkout, the runner, and the 30-minute timeout.docs/ci-cd-setup.mdalready concedes "Job ist rot oder gelb". - The runner is documented as the single runner. Every push to
maintherefore holds it for up to 30 minutes afterpublish. The next push'squality/testwaits in the queue, which contradicts the plan truth "never ... delays quality, test, desktop or publish".
Fix: After the first push, check that the run shows green/neutral and not red (security red =
unsupported). If the single runner is the bottleneck, move the scan to a scheduled workflow
(on: schedule, nightly) plus tags v*, or cut the job budget (see WR-04). Keep the existing
if: gitea.ref == ... line.
WR-04: Per-tool timeouts add up to far more than the job timeout, and the summary is only printed at the very end
File: .gitea/scripts/security-scan.sh:264,286,298-300,333,359,375,403,424,583-597
Issue: The sum of the limits is about 600 (pipx) + 120 (corepack) + 3 x 600 (curl) + 900 (gitleaks) +
300 (audit) + 600 (osv) + 1500 (semgrep) + 900 (trivy fs) + 2 x 900 (images), roughly 8,500 s, against
timeout-minutes: 30 (1,800 s). A slow Semgrep or Trivy run makes the runner kill the whole job before
summarize runs. The log then holds no SECURITY-SUMMARY lines and the report directory may not be
uploaded. The documented guarantee "the SECURITY-SUMMARY lines in the log are always enough" fails in
exactly the slow case.
Fix: Either print a one-line count right after each tool, or keep one global deadline. For example,
compute DEADLINE=$(( $(date +%s) + 1500 )) and let tmo use min(limit, DEADLINE-now). Also lower
Semgrep to about 600 s.
WR-05: ZAP basic-auth replacer rule is not scoped to the target and can send alpha credentials to other hosts
File: .gitea/scripts/zap-hooks.py:23-32
Issue: With ZAP_BASIC_AUTH_FILE set, a global replacer rule (initiators="", no URL restriction)
overwrites Authorization on every request that ZAP sends. That includes any redirect target, and ZAP's
own add-on/update traffic if it is enabled, not only requests to alpha. The credentials are the Basic
Auth of the test server. The same file sits in the report (documented), so the report must not be passed
on. The rule itself adds a second exposure. It only matters when the manual run needs the creds, but it
is avoidable.
Fix: Scope the rule to the target, e.g. pass url=re.escape(target) + '.*' to replacer.add_rule
(the url parameter exists in current Replacer API versions), and use ZAP_TARGET from zap_started's
target argument.
Info
IN-01: Hard-coded Yarn path makes the hardening silently disappear on a base-image bump
File: apps/api/Dockerfile:52-53, apps/web/Dockerfile:47-48
Issue: rm -rf ... /opt/yarn-v1.22.22 ... names one exact version. node:24-alpine floats; when the
image ships a different Yarn, rm -rf of a missing path succeeds and Yarn (and its transitive
advisories) remain with no build failure. The same line is copied into two Dockerfiles.
Fix: rm -rf /opt/yarn-v* ... and add RUN ! command -v npm && ! command -v yarn && ! command -v corepack && node --version
so the build fails if the removal stops working.
IN-02: Headers duplicated by the proxy may conflict, contrary to the comment in next.config.ts
File: apps/web/next.config.ts:31-40
Issue: The comment says that if the reverse proxy sets the same headers "nothing is gained or lost".
That is true only for identical values. If NPM later adds X-Frame-Options: DENY or its own CSP,
browsers treat two conflicting values as the stricter one (or ignore XFO), which could block Tessera
being framed by itself or produce two CSPs. The first public ZAP run (still outstanding per the
protocol) is the point to check it.
Fix: After the public-URL ZAP run, record that the proxy sends none of these headers. Leave the
comment otherwise as is, but drop the "nothing lost" claim.
IN-03: zap-baseline.sh leaves the report folder world-writable and breaks on credentials with quotes
File: .gitea/scripts/zap-baseline.sh:92,111
Issue: chmod 777 "$REPORT_DIR" is done so the container user (uid 1000) can write, but the report
can contain Basic Auth credentials (documented). Any local user can read it. Separately,
printf 'user = "%s"\n' makes curl's config parser stop on a " or \ in the password, which then
aborts (exit 4, fail-closed, but confusing).
Fix: Use chmod 770 and chown/--user "$(id -u):$(id -g)" for the container instead of 777, and
escape \ and " in CRED before writing the curl config.
IN-04: Shallow clone would silently shorten the gitleaks history scan
File: .gitea/scripts/security-scan.sh:325-337
Issue: ci.yml uses fetch-depth: 0, but the script has no check. Running it from a shallow local clone
still reports gitleaks findings=0 for the visible commits only.
Fix: git -C "$ROOT" rev-parse --is-shallow-repository and emit
SECURITY-SUMMARY gitleaks ... note=flaches-repository when true.
Reviewed: 2026-10-09 Reviewer: Claude (gsd-code-reviewer) Depth: standard
Fix status
Fixed 2026-10-09 in commits bf632d9 (code) and 8a1218a (docs). Not pushed.
| Finding | Status | What was done / proof |
|---|---|---|
| WR-01 | fixed | apps/api/Dockerfile (stage prod-deps): explicit prisma generate plus a check that .prisma/client/index.js exists, no ` |
| WR-02 | fixed | Semgrep runs from semgrep/semgrep@sha256:529ee8a2... (digest pinned, docker create + docker cp in/out, because the job path is not a host path). pipx/PyPI path removed. Exit-0 and SHA256 checks for the other tools unchanged. |
| WR-03 | open, by design | Verify after the push: job security shows green/neutral (not red) on the real Gitea, continue-on-error and timeout-minutes honoured, and the single runner is not blocked (see SUMMARY checklist). Recorded as open row in the protocol. |
| WR-04 | fixed | Global budget BUDGET_SECONDS=1500; per-tool limits sum to 1485 s (install 375, checks 1110); tmo clamps to remaining time; each tool prints its SECURITY-SUMMARY line immediately and appends to summary.txt; reasons zeitgrenze / gesamtbudget. Proven with forced limits (semgrep 2 s, trivy 1 s, budget 6 s). Full run in the runner image: 63 s, rc 0. |
| WR-05 | fixed | Replacer rule gets url=<escaped target>(?:[/?#].*)?. Proof through a ZAP daemon proxy: target 127.0.0.1:2000 got the header; 127.0.0.1:20001 (same host, prefix port) and 127.0.0.2:2002 (second host) did not. |
| IN-01 | fixed | rm -rf /opt/yarn* /usr/local/bin/yarn* ... plus a loop command -v over npm/npx/corepack/yarn/yarnpkg and an ls absence check, in both Dockerfiles. Proof: a Dockerfile that leaves npm in place fails the build. |
| IN-02 | fixed | Comment in apps/web/next.config.ts now says differing proxy values apply side by side (stricter wins) and points to the public ZAP run. next-config.test.ts 4/4, biome clean. |
| IN-03 | fixed | Report dir chmod 700 (uid 1000 case); other uids hand the dir over to 1000 and back via a short container run (path exercised with a faked uid). Credentials only as base64 in a 0600 header file (curl -H @file) and env file; no curl config string. Proof: password with " and \ passes the pre-check, ZAP run 0 POST, 8 of 9 requests with header (the 9th is the pre-check). |
| IN-04 | fixed | git rev-parse --is-shallow-repository; gitleaks line reads findings=0 unvollstaendig (flacher Klon: ...). Proven with a --depth 1 clone. |