## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata) — controls 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4 ## Status: complete ## Last updated: 2026-08-27 ## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md ## --- # 07 — BIO2 / ISO 27002:2022 compliance **Scope, in full.** Frontend: `ssp/auth`, `ssp/registratie`, `ssp/herregistratie`, `ssp/brief`, `ssp/showcase+shell+root`, `bhp/auth`, `bhp/behandeling`, `bhp/shell+root`, `libs/shared` (domain, application, infrastructure, ui, layout, kernel, upload, testing, environments), `libs/beheer`. Backend: `Program.cs`, `Domain`, `Data`, `Zgw`, `Contracts`, `Stamdata`. Controls: 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4. --- ## 0. The control-set assumption, stated for the record **No explicit control list was supplied to this agent.** The seven areas below were selected for privacy and security relevance to a BIG-register portal handling BSN, diploma and health-professional registration data: | # | Area | ISO 27002:2022 / BIO2 | | --- | ------------------------------ | --------------------- | | 1 | Access control | 9.1, 9.2, 9.4 | | 2 | Logging & monitoring | 8.15, 8.16 | | 3 | Data classification & handling | 5.12, 5.13 | | 4 | Cryptography | 8.24 | | 5 | Secure development | 8.25, 8.28, 8.29 | | 6 | Change control | 8.32 | | 7 | Input validation | 8.26 | **Flag if a different set should apply.** Three plausible narrowings/widenings a reviewer should decide on before this file is treated as authoritative: - **BIO 2.0 thema-uitwerkingen** rather than raw ISO 27002 — a Dutch government system would normally be assessed against the BIO's own thematic elaborations (toegangsbeveiliging, logging & monitoring), which are stricter on logging retention and on the "verwerking van bijzondere persoonsgegevens" than the bare ISO controls used here. Nothing below would be withdrawn under that set; several items would rise in severity. - **NEN 7510** (Dutch healthcare information security) is arguably the governing standard for a register of healthcare professionals. Not applied here. - **AVG/GDPR obligations proper** (art. 5 minimisation, art. 9 special-category, art. 30 register of processing, art. 32 measures) are referenced only where the code itself invokes them. A DPIA is out of scope for this pass and is listed in the pre-production checklist. **Framing, per the brief.** This POC has deliberately faked authentication; CLAUDE.md's "Out of scope" excludes real auth/DigiD and PRD-0002 §3 excludes real AD/OIDC/SAML. **No finding below asks for real DigiD or employee SSO.** Findings are split: - **Defect now** — wrong even for a POC. Typically: PII reaching a store or a log that the code's own contract says holds none, or a missing authorization check that has nothing to do with the identity stub. - **Production gate** — correct for a POC, must be true before production. These are the pre-production checklist at the end. Nine of the twenty findings are **defect now**. Where a dev-only affordance is genuinely stripped from a production build, that is said plainly and no finding is filed (see §1.4 and the module notes). --- ## 1. Control area: access control (9.1, 9.2, 9.4) ### BIO-001 — the backend trusts client-asserted identity headers in every environment - **Control:** 9.2 (user access provisioning), 9.4 (least privilege / secure log-on) - **Class:** **production gate** - **Severity: high** — a single request header grants the full admin capability set. It is high not because it is unknown (it is documented in three places) but because it is the item on which every other authorization control in the system rests: `Authz`, the capability model, the four-eyes rule and the audit trail are all correct _given_ a trustworthy `Principal`, and all worthless without one. - **Evidence (read):** - `backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs:23-27` — `ctx.Request.Headers["X-Role"]` maps directly to `PrincipalRole.Admin`. No verification, no signature, no environment guard. - `:29-31` — `X-Medewerker` present ⇒ `MedewerkerCaller`, id taken verbatim from the header; `:38-46` `X-Rollen` likewise. - `:33-35` — `X-Subject` sets the caller's BSN, i.e. the ownership key every owner-scoped store reads. - `backend/src/BigRegister.Api/Program.cs:53` registers this as the only `IIdentityProvider` unconditionally; `:114-119` runs it as middleware for every request in every environment. - `Domain/Authorization/Authz.cs:12-16` and `StubIdentityProvider.cs:7-9` both label it "dev stub — NOT a security boundary". The labelling is accurate and complete; **nothing in the build enforces it.** - **Baseline citation:** §7 Backend pattern inventory — "Single-impl interface | `IIdentityProvider` → `StubIdentityProvider`"; **BL-006** (the backend has zero automated architecture enforcement, so nothing would fail a build that shipped this stub). - **Remediation, minimal:** do not replace the stub in this backlog. Two cheap, in-scope steps: (a) fail fast — throw at startup when `builder.Environment.IsProduction() && provider is StubIdentityProvider`, so the stub can never boot outside Development; (b) give `IIdentityProvider.Resolve` a way to say "no identity" (see BIO-002), so the production swap is a drop-in rather than a redesign. - **Effort:** S (a), S (b). The real provider is out of scope and is a checklist item, not a ticket. ### BIO-002 — in a production build the backoffice has no identity, and the default is a citizen _Agent 06 handed this over explicitly (`06-adr-conformance.md`, "Observation for agent 07"). Here is what it actually means for 9.4._ - **Control:** 9.4 (least privilege), 9.2 (provisioning); PRD-0002 §4 goal 4 (deny-by-default) - **Class:** **production gate** - **Severity: high** — the failure mode is an _identity substitution_, not merely a missing identity, and it fails open in the direction nobody checked. - **Evidence (read), and the answer to "what identity does a production backoffice user get":** - `apps/behandelportal/src/app/app.config.ts:57-63` — `medewerkerInterceptor` is inside the `isDevMode()` array. A production bundle sends **no** `X-Medewerker` / `X-Rollen`. - `StubIdentityProvider.cs:29-37` — with no `X-Medewerker` and no `X-Subject`, the provider falls through to `new ZorgverlenerCaller(DocumentStore.DemoOwner, SeedData.Registration.Naam, PrincipalRole.Drafter)`. `DocumentStore.cs:48` — `DemoOwner = "123456782"`, the single seeded citizen's BSN. - **So a production backoffice user authenticates to the backend as the seeded citizen, role `drafter`.** Concretely: - **Fails closed, correctly, on the backoffice capability.** `Authz.CanBeoordelen(caller)` is `caller is MedewerkerCaller m && …`, so a zorgverlener is `false` regardless of `X-Role`. `GET /werkvoorraad`, `GET /beoordeling/{id}` and `POST /beoordeling/{id}/besluit` all 403 through the `Beoordelen` gate (`Program.cs:814-820`) and write a deny audit row. `GET /me` returns an empty capability list, so `capabilityGuard('aanvraag:beoordelen')` (`app.routes.ts:25`) denies too. This part of the design is right and should be recorded as such. - **Fails open on the citizen's own rights.** The same user _is_ the seeded citizen for every citizen-scoped endpoint: `GET /applications`, `GET /applications/{id}`, `PUT/DELETE /applications/{id}`, `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`, `POST /brief/submit|send|reset` (`Program.cs:281-372`, `:253`, `:603-656`) all resolve `ctx.Zorgverlener().Bsn` to `123456782`. An employee with no employee identity is granted a **citizen's** read and write rights over that citizen's aanvragen, uploads and letters. - **Holds the PII-reveal capability.** `Authz.CanRevealBigNummer(principal)` is `principal.Role == PrincipalRole.Drafter` — and `drafter` is exactly the role the no-header default produces. See BIO-006. - **Root cause, and why it is worth a ticket now:** `IIdentityProvider.Resolve` returns a non-nullable `CallerIdentity` (`IIdentityProvider.cs:12`). The interface **cannot express "no identity"**, so any implementation — stub or real — is forced to invent one for an unauthenticated request. `CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws rather than defaulting when the middleware did not run, i.e. the codebase reaches for fail-loud one layer up and then defaults one layer down. - **Baseline citation:** §7 Backend — "Single-impl interface | `IIdentityProvider` → `StubIdentityProvider`"; §2 size inventory (`apps/behandelportal` 29 src files, 1 309 lines — a whole app with no non-dev identity path). - **Remediation, minimal:** change `Resolve` to `CallerIdentity?` and have the middleware either reject (401) or set an explicit `AnonymousCaller` when it returns null; keep `StubIdentityProvider` returning the current default **only** under `IHostEnvironment.IsDevelopment()`. That is the smallest change that makes "unauthenticated" representable, and it is the natural home for agent 06's **ADR-C-004** (`Session → Principal`). - **Effort:** S for the interface + middleware; the behandelportal's real login is out of scope. ### BIO-003 — `X-Admin` is a second authorization gate, outside `Authz`, unaudited, with no caller - **Control:** 9.4; 8.15 (audit trail); PRD-0002 §7 ("a single shared authorization helper … used on every endpoint … the helper makes emit and enforce the same code path") - **Class:** **defect now** - **Severity: medium** — a destructive cross-owner delete behind the weakest gate in the system, leaving no audit record. Not high only because the stronger gate it should use is itself header-asserted today (BIO-001). - **Evidence (read):** - `Program.cs:773` — `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";` - `Program.cs:268-270` — `DELETE /admin/uploads/{documentId}` is gated by `IsAdmin` alone, not by `Authz.CanManageCases` / the `CasesAdmin` wrapper the four sibling admin surfaces use (`:801-808`). - `Data/DocumentStore.cs:165-179` — `AdminDelete` "bypasses ownership", deletes the row and its bytes, and writes only a `DocumentStore.Audit("delete-admin", …)` metadata row — **no** `AuthzAuditStore` entry, so the action never appears on `/beheer/audit`. - `grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e`: the only sender is `backend/tests/BigRegister.Tests/EndpointTests.cs:231`. **No frontend uses this endpoint.** It is an orphaned gate, not a live seam. - **Baseline citation:** **BL-003** (940 lines, 48 endpoint mappings in one file, read/write separated only by a comment banner — the structural condition under which one endpoint keeps a superseded gate); §7 Backend CQRS-light row (the local helpers `Submit`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin` are "authorization/idempotency wrappers" — `IsAdmin` is the one that never became a wrapper). - **Remediation, minimal:** route the endpoint through `CasesAdmin` (or a new `Authz.CanDeleteAnyDocument`) and delete `IsAdmin`; add the `AuditAuthz` call the sibling gates make. One test (`EndpointTests.cs:231`) changes its header. - **Effort:** S ### BIO-004 — two upload endpoints have no authorization check at all - **Control:** 9.4 (broken object-level authorization); 5.12 (the objects are diploma and identity scans) - **Class:** **defect now** - **Severity: high** — it is the only place in the backend where AVG-relevant _content_ is served with no owner and no capability test, and the codebase demonstrably knows the pattern: the sibling `DELETE` on the same resource is owner-scoped, and submit validates foreign ids. Mitigating factor, stated honestly: document ids are `Guid.NewGuid()` (`DocumentStore.cs:54`) and local ids are `crypto.randomUUID()` on the client, so this is a capability-URL exposure rather than an enumerable one. - **Evidence (read):** - `Program.cs:231-237` — `GET /uploads/{documentId}/content` calls `DocumentStore.Get(documentId)` and streams `doc.Content`. The lambda does not take `HttpContext`; it cannot check anything. - `Data/DocumentStore.cs:65-72` — `Get` has no owner parameter. - Contrast, one screen away: `Program.cs:253-254` `DELETE /uploads/{documentId}` → `DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn)` (`:146-153`, explicit `d.Owner != owner` check), and `Program.cs:367` → `DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn)` (`:103-113`), whose own docstring says it "guards submit/draft-sync against a citizen attaching another citizen's upload to their own aanvraag". - `Program.cs:242-250` — `GET /uploads/status?localIds=` maps client local ids to document ids for **any** caller, with no owner filter (`DocumentStore.ByLocalIds`, `:76-84`). - **Why it is unscoped is legible:** `Program.cs:451-452` (the beoordeling detail) hands a behandelaar the `DocumentId`s of another citizen's uploads, so a cross-owner read is a genuine requirement. The defect is that the requirement was met by removing the check rather than by widening it. - **Baseline citation:** §7 Backend — `DocumentStore` listed among the 7 stores "Not behind any port"; §3c `backend/Data` **75.5% branch** against 99.0% line (**BL-005**), the exact signature of "the unit is entered, the guard branches are not there to enter". - **Remediation, minimal:** take `HttpContext` in both lambdas and allow when `doc.Owner == ctx.Caller().SubjectId` **or** `Authz.CanBeoordelen(ctx.Caller())` **or** `Authz.CanManageCases(Authz.ResolvePrincipal(ctx))`; 404 (not 403) otherwise, per PRD-0002 §8's "avoid resource-existence enumeration". Same predicate for `/uploads/status`. - **Effort:** S ### BIO-005 — `POST /registrations` links arbitrary document ids with no ownership check - **Control:** 9.4; 8.26 (unvalidated request field driving a state change) - **Class:** **defect now** - **Severity: medium** — integrity/availability, not confidentiality: the caller cannot read another citizen's document, only permanently mark it `Linked`, which blocks that citizen from ever deleting it (`DocumentStore.DeleteOwned` returns `Linked`, `Program.cs:257-259`). - **Evidence (read):** - `Program.cs:187-190` — `POST /registrations` passes `req.Documents` straight to `Submit`. - `Program.cs:919-925` (`Submit`) — `DocumentStore.Link(documents.Where(…).Select(d => d.DocumentId!))` and `DocumentStore.Audit("post-delivery", …)`. Neither is owner-scoped; `DocumentStore.Link` (`Data/DocumentStore.cs:129-141`) takes no owner at all. - Contrast `Program.cs:367`, the newer submit path, which rejects foreign ids before linking. - **The endpoint has no frontend caller.** `grep` over `apps`/`libs` for the generated client's `registrations` method returns nothing outside `api-client.ts` itself; only `/change-requests` is still called (`registratie/infrastructure/change-request.adapter.ts:17`). - **Baseline citation:** **BL-003** (48 endpoint mappings in one 940-line file — the condition under which a guard added on one submit path is not added to the other); §7 Backend CQRS-light row, which names `Submit` as one of the cross-cutting wrappers. - **Remediation, minimal:** either delete the endpoint (it is dead, and WP-72 already removed its siblings), or add the one `ForeignIds` guard the other submit path uses. Deleting is smaller and removes the surface entirely. - **Effort:** S ### BIO-006 — the PII-reveal capability belongs to the default role, and its step-up is a client-asserted constant - **Control:** 9.4 (least privilege); PRD-0002 §5d ("the server re-checks the environment attribute before permitting") - **Class:** **production gate** (the role mapping is a documented POC choice) with one **defect-now** sub-item (the step-up check is a no-op as wired) - **Severity: medium** - **Evidence (read):** - `Domain/Authorization/Authz.cs` — `CanRevealBigNummer(principal) => principal.Role == PrincipalRole.Drafter`. - `StubIdentityProvider.cs:23-27` — the `_ =>` arm of the role switch is `Drafter`. So the **absence** of any role header yields the role that holds the PII reveal. `roles-and-access.md` states this as "`drafter` … the only role that may reveal a BSN" without noting that it is also the default. - `Program.cs:668-681` — the reveal requires `canReveal && ctx.Request.Headers["X-Step-Up"] == "true"`. - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — the client sends `'X-Step-Up': 'true'` **unconditionally, as a literal**. The precondition is therefore satisfied by every call that reaches the endpoint; it constrains nothing. The endpoint's own comment at `Program.cs:665` calls it "stubbed here as the X-Step-Up header", which is accurate, but the FE side turns the stub into a constant rather than into a gesture-gated value. - **Baseline citation:** §7 Backend — `Domain/.../Authz.cs` listed among the pure rule classes with co-located tests; §3b `ssp/brief` **42% spec reach** (11 of 26 files), with `reveal-bignummer.adapter.ts` among the 15 unreached and **not** a `ui/` file, so BL-004's Storybook carve-out does not cover it. - **Remediation, minimal:** (a) send `X-Step-Up` only from the confirm handler, not from the adapter's literal — one line, and it makes the stub behave like the control it stands in for; (b) record in `roles-and-access.md` that `drafter` is the default role, so the least-privilege consequence is visible; (c) production gate: bind the reveal to an app-overlay attribute rather than the coarse role, as PRD-0002 §5c already says ("Role-based in the POC; a real system resolves it from the app overlay independent of role"). - **Effort:** S for (a)+(b); the overlay is a checklist item. ### BIO-013 — the seeded-citizen endpoints ignore the caller entirely - **Control:** 9.4 (row-level scoping); 5.12 - **Class:** **production gate** — explicitly acknowledged as unbuilt in PRD-0002 §9 P2 ("Row-level scoping (§5b) still unbuilt") - **Severity: medium** — with one seeded citizen it is invisible; the moment a second identity exists (which `?subject=` already creates in e2e) every citizen reads the seeded citizen's BRP address, birthdate and registration. - **Evidence (read):** `Program.cs:135-155` — `GET /dashboard-view`, `GET /notes`, `GET /brp/address`, `GET /duo/diplomas` take no `HttpContext` and return `SeedData.Registration` / `SeedData.Person` / `SeedData.BrpAddress` regardless of the resolved caller. The identity middleware runs (`:114-119`) and its result is discarded. - **Baseline citation:** §7 Backend — the read side is "screen-shaped reads. Decisions are computed here"; **BL-003** (all 48 mappings in one file). §3c `Program.cs` 97.4% line — these endpoints are covered, so the gap is by design, not by omission. - **Remediation, minimal:** none proposed for the POC. The checklist item is: every read that returns person data must take the caller and scope on it, and the acceptance test is a second seeded citizen who cannot see the first's data. - **Effort:** M (out of this backlog) ### BIO-018 — `IdempotencyStore` is keyed on a client-supplied string with no caller scoping, TTL or bound - **Control:** 9.4; 8.26 - **Class:** **defect now** - **Severity: low** — the cached values are only a `ReferentieResponse` or a ProblemDetails, so a cross-caller replay leaks a reference number, not personal data. Filed because it is an unscoped shared cache in an access-control path and the fix is trivial. - **Evidence (read):** `Data/IdempotencyStore.cs:11-27` — a process-global `Dictionary` keyed on the header alone; the file's own `ponytail:` comment concedes "no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory leak at scale". `Program.cs:901-909` reads and writes it with the raw header value, never composed with the caller's `SubjectId`. - **Baseline citation:** §7 Backend — `IdempotencyStore` listed among the 7 stores "Not behind any port"; agent 02's `backend/Data` note ("the only store that is purely in-memory with no `Reset()` and no TTL … shared by every test class in the process"). - **Remediation, minimal:** key on `$"{ctx.Caller().SubjectId}:{idemKey}"`. One line, and it also removes the cross-test-class bleed agent 02 flagged. - **Effort:** S --- ## 2. Control area: logging & monitoring (8.15, 8.16) ### BIO-007 — only _denied_ authorization decisions are audited; successful admin and approval actions are not - **Control:** 8.15 (logging), 8.16 (monitoring activities); PRD-0002 §8 ("Audit log of authorization-relevant events — **denials, PII reveals, approvals/rejections**, step-up, break-glass") - **Class:** **defect now** - **Severity: medium** — the queryable trail the product ships as its audit surface (`/beheer/audit`) cannot answer "who changed this", only "who was turned away". For a register whose integrity is the product, that is the wrong half. - **Evidence (read) — every `AuditAuthz` call site, checked:** - `Program.cs:783` `OrgAdmin` → `allowed: false`. `:794` `StamdataAdmin` → `false`. `:805` `CasesAdmin` → `false`. `:817` `Beoordelen` → `false`. `:827` `FlagsAdmin` → `false`. All five gates audit **only** the denial branch; the allow branch calls `action()` and returns. - The one exception is `Program.cs:674` (the reveal), which passes the real `allowed`. `:539` audits the NRC notification. `:856` audits a ZGW divergence. - **Therefore the following leave no row in `AuthzAuditStore`:** `PUT /admin/flags/{key}` (`:592`, and `Data/FeatureFlagStore.cs:54-66` writes nothing either — the endpoint does not even emit a log line), `PUT /admin/org-template/{subOrgId}` (`:739`), `POST /admin/org-template/{subOrgId}/rollback/{version}` (`:764`), `DELETE /admin/cases/{id}` (`:554`, log line only at `:557`), `DELETE /admin/uploads/{documentId}` (`:268`, see BIO-003), `POST /brief/approve` / `/reject` / `/send` (`:631-658`, `LogBrief` writes a log line with no actor), and `POST /beoordeling/{id}/besluit` (`:473`). - The comment at `Program.cs:777-778` states the intent — "the allow path is left un-logged (the endpoints log their own effect, e.g. publish)" — and the intent is only half met: publish (`:755`) and admin case delete (`:557`) log; the other six do not log at all. - **Baseline citation:** §7 Backend — `AuthzAuditStore` listed among the 7 static stores; §3c `backend/Program.cs` 97.4% line / **84.8% branch** — the allow branches run constantly and simply have nothing in them. - **Remediation, minimal:** move the `AuditAuthz` call from each gate's deny branch to the gate itself, passing the real boolean and wrapping `action()`: `var ok = Authz.CanX(p); AuditAuthz(ctx, "x", resource, ok, p); return ok ? action() : Forbidden(...)`. Five identical edits in `Program.cs:779-830`, no signature change, no new concept. Add `AuditAuthz` to the three brief transitions and the besluit. - **Effort:** S for the five gates; M including the brief/besluit sites and their tests. ### BIO-008 — the BSN is written into the authz audit trail's `Resource` column - **Control:** 8.15; 5.12 (classification of special-category data) - **Class:** **defect now** - **Severity: high** — this is the single clearest "wrong even for a POC" item in the file: a national identifier is persisted to a store whose own type documentation, class documentation and endpoint documentation all state it holds none, and it is then served over an API and rendered in a UI that repeat the same claim. - **Evidence (read), the whole chain:** 1. `Program.cs:674` — `AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);` The BSN is concatenated into the `resource` argument. 2. `Program.cs:840-844` — that argument is logged (`"authz action={Action} resource={Resource} …"`) **and** passed to `AuthzAuditStore.Record(action, resource, …)`. 3. `Data/AuthzAuditStore.cs:30-38` — `Record` inserts it as `AuthzAuditEntry.Resource` into SQLite. The entity's doc comment (`:5-8`) says "**never** a name, BSN, or the value that was (or wasn't) revealed"; the class comment (`:22-23`) says "Holds NO PII by construction (see the entity); the schema test asserts it". 4. `Program.cs:566-569` — `GET /admin/audit` returns `a.Resource` verbatim in `AuthzAuditDto`. 5. `libs/beheer/src/ui/audit.page.ts:10-12` — the page's own header comment reads "data-minimised, no PII", and its table renders the resource column. 6. `Program.cs:666-667` — the endpoint comment: "every attempt — allow or deny — is audited with **NO PII** (AuditAuthz)". - **The false assurance, named:** `backend/tests/BigRegister.Tests/AuthzAuditTests.cs:51-53` asserts over **column names** — `Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …))` — not over values. The BSN travels in a column called `Resource`, which the regex cannot see. Four documents claim the control; the test that is cited as enforcing it does not enforce it. - **Baseline citation:** §7 Backend — the 7 static stores, `AuthzAuditStore` among them; §3c `backend/Data` **75.5% branch** (**BL-005**). - **Remediation, minimal:** the resource ref for a per-owner brief does not need the BSN — use the brief's own id, or `"brief/" + MaskTail(bsn, 3)` (the masker already exists at `Program.cs:861-863` and is already used for the BIG-nummer at `:878`). Then extend `AuthzAuditTests` to assert on **values**: seed a reveal attempt and assert no stored `Resource` matches `\d{9}`. - **Effort:** S ### BIO-009 — the BSN is the `Actor` on every document audit row - **Control:** 8.15; 5.12 - **Class:** **defect now** - **Severity: medium** — same class as BIO-008 but a narrower blast radius: this table is not exposed by any endpoint (`grep`: `DocumentStore.AuditLog` has no caller in `Program.cs`), so it is a storage-side leak only. - **Evidence (read):** - `Data/DocumentStore.cs:52-63` — `Add(..., string owner)` ends with `Audit("upload", doc.DocumentId, categoryId, owner)`, and `owner` is the caller's BSN (`Program.cs:224` passes `ctx.Zorgverlener()`, whose `Bsn` becomes `StoredDocument.Owner`). - `:159` — `DeleteOwned` likewise: `Audit("delete-user", documentId, categoryId, owner)`. - `:181-189` — `Audit` persists it as `AuditEntry.Actor`. - The class doc comment (`:30-36`) states "The audit log holds metadata only (never file content **or other PII**)." - `StoredDocument.Owner` itself is the BSN by design (it is the ownership key) — that is correct and is **not** the finding; the finding is the _audit_ row, which needs only a pseudonymous actor. - **Baseline citation:** §7 Backend — `DocumentStore` among the 7 static stores; §3c `backend/Data` 99.0% line / 75.5% branch. - **Remediation, minimal:** pass `MaskTail(owner, 3)` (or a per-session pseudonym) as the `actor` argument at `:61` and `:159`; the ownership column is untouched. - **Effort:** S ### BIO-010 — the BSN reaches logs and a persisted aanvraag field through the ZGW error path - **Control:** 8.15; 5.12 - **Class:** **defect now**, conditional on `Zgw:Enabled=true` (off by default, `appsettings.json`) — which is why the severity is medium and not high today, and why it becomes high the moment OpenZaak is switched on. - **Severity: medium** - **Evidence (read):** - `Zgw/OpenZaakZaakSource.cs:52` — the citizen-scoped list builds `url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}"`. The BSN is in the request URI. - `Zgw/ZgwHttpClient.cs:76-81` — on a non-transient failure the client throws `new HttpRequestException($"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}")`, i.e. the exception message carries **both** the BSN-bearing URI **and** up to 500 characters of the OpenZaak response body. `Zgw/OpenZaakZaakSource.cs:246` shows the write side posts `inpBsn` in the body, and `Data/DocumentStore.cs:44-47` records that OpenZaak's own validation errors for that field are a real, observed failure mode — so the body snippet is a live BSN-echo path, not a hypothetical one. - `Program.cs:851-856` (`RecordZgwDivergence`) — `app.Logger.LogError(ex, …)` logs that message, **and** `ApplicationStore.SetZgwError(id, ex.Message)` persists it to the aanvraag row (`Data/ApplicationStore.cs:286-294`). `Data/AanvraagMapper.cs:25,50,63,78,96` carry it onward through the mapper. - Mitigating, verified: `ZgwError` is **not** on any DTO in `Contracts/` and does not appear anywhere in the frontend — the value is stored and logged, not served. - Also verified and **not** a finding: `Zgw/ZgwDiagnosticHandler.cs:17-24` logs only method, URI and byte counts, and only when `request.Content is not null` — so the BSN-bearing GET URI is never reached by it, and no body is logged. It is opt-in behind `ZGW_DEBUG_HTTP=1` (`Program.cs:73-78`). That hatch is clean. - **Baseline citation:** §7 Backend — "ZGW anti-corruption layer | Fully built"; §3c `backend/Zgw` 98.1% line / **85.5% branch — the strongest branch figure on the backend**, so this is a design gap, not a test gap. - **Remediation, minimal:** in `ZgwHttpClient.SendWithRetryAsync`, build the message from `req.RequestUri.GetLeftPart(UriPartial.Path)` (drop the query) and omit the body snippet from the _message_, logging it separately at Debug if the diagnostic value is wanted. - **Effort:** S --- ## 3. Control area: data classification & handling (5.12, 5.13) ### BIO-011 — cross-owner list endpoints ship unmasked BSNs while the detail endpoint masks - **Control:** 5.12 (classification), 5.13 (labelling/handling); PRD-0002 §5c ("Default DTO carries a **masked** BSN … or omits it entirely") - **Class:** **defect now** - **Severity: medium** — the correct behaviour is implemented one file away, so this is an inconsistency rather than a missing capability, and the list is the _wider_ exposure (every open case, not one). - **Evidence (read):** - `Contracts/Mappers.cs:72-74` — `ToAdminSummaryDto(now) => a.ToSummaryDto(now) with { Owner = a.Owner }`. `a.Owner` is the raw BSN. `Contracts/Dtos.cs:107` documents the field as "populated for the admin cross-owner list (WP-36)". - `Data/LocalZaakSource.cs:15-16` — `ListCases` maps every row through `ToAdminSummaryDto`. - `Program.cs:425-427` — `GET /admin/cases` returns `zaken.ListCases(...)` unmodified. `Program.cs:434-438` — `GET /werkvoorraad` returns the same list, filtered by status only. - **Contrast, in the same file:** `Program.cs:453` — the beoordeling detail does `var masked = c with { Owner = MaskTail(c.Owner!, 3) };` before returning. The detail masks; the list that leads to it does not. - Rendered unmasked in both UIs: `apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts:28` (`subtitle: $localize\`:@@werkvoorraad.row.bsn:BSN ${item.owner}\``) and `apps/ssp/src/app/registratie/ui/admin-cases.page.ts:91`("Eigenaar (BSN)").`behandeling/domain/beoordeling.ts:26`correctly documents its own field as "masked by the server";`werkvoorraad-item.ts:20` documents its as "always populated" with no masking note. - **Baseline citation:** §7 Backend — "Mapping | `Contracts/Mappers.cs` (`.ToDto()`, `.ToDetailDto()`), `Data/AanvraagMapper.cs`" (the exact seam); §3a `bhp/behandeling` 91.6% line / 81.5% branch — well tested, so nothing here is accidental. - **Remediation, minimal:** apply `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` (`Mappers.cs:74`) — one expression, and both list endpoints inherit it. If an admin genuinely needs the full value, that is a reveal action with the existing gated+audited shape, not a default list field. - **Effort:** S ### BIO-012 — `?role=` and `?subject=` are **not** stripped from production builds on three hand-written `fetch` paths - **Control:** 5.13 (handling — a BSN written to web storage and onto the wire), 9.4 - **Class:** **defect now** — the defect is that a documented control does not hold, not that the residual exposure is large. Stated plainly: an attacker who can send a header does not need this path (see BIO-001), so the incremental attack value is low. The value of the finding is that the docs and CLAUDE.md assert a production property the code does not have, and anyone reasoning about production risk from those docs will get it wrong. - **Severity: medium** - **Evidence (read):** - The interceptor chain **is** correctly gated: `apps/ssp/src/app/app.config.ts:58-62` and `apps/behandelportal/src/app/app.config.ts:57-63` both register `[scenarioInterceptor, roleInterceptor, subjectInterceptor(, medewerkerInterceptor)]` only when `isDevMode()`. That much of the claim is true. - But three adapters bypass `HttpClient` entirely and set the headers themselves, with **no** `isDevMode()` guard: - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — `headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }` - `apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts:43-46` — `headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }` - `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:79` — `headers: { 'X-Role': currentRole() }` - The readers are ungated too: `libs/shared/src/infrastructure/role.ts:23-31` and `libs/shared/src/infrastructure/subject.ts:23-29` both read the query param and **write it into `sessionStorage`** on any navigation, in any build. For `?subject=` that value is a **BSN** — persisted to web storage under `dev-subject`, then sent as `X-Subject` on `/brief/preview`. `subject.interceptor.ts:14-29` argues at length that the BSN must not leave `SessionStore` ("`SessionStore`'s G1 comment is explicit that the BSN … is never persisted or otherwise handed outward, by design") and then routes it through `sessionStorage` instead. - The claim being contradicted: `docs/reference/roles-and-access.md:23` — "Both are wired only under `isDevMode()` — **they do not exist in a production build**"; CLAUDE.md's "Scenario toggle (**dev-only**, not wired in prod builds)" and "Dev role stand-in (**dev-only**)". `?scenario=` genuinely is stripped (its only consumer is the gated interceptor plus `upload.adapter.ts`'s simulator); `?role=` and `?subject=` are not. - BSN-in-URL has its own consequences independent of the header: browser history, `Referer`, and any reverse-proxy access log. - **Baseline citation:** §3b `ssp/brief` **42% spec reach** (11 of 26 files) — all three hand-written `fetch` adapters are among the 15 unreached, and none is a `ui/` file, so **BL-004**'s Storybook carve-out does not cover them; §3a `ssp/brief` 68.8% branch. - **Remediation, minimal:** guard the three adapters — `...(isDevMode() ? { 'X-Role': currentRole() } : {})` — or, cleaner, have `currentRole()` and `currentSubject()` return `undefined` outside `isDevMode()` so every caller inherits the guard and the sessionStorage write disappears with it. Then correct `docs/reference/roles-and-access.md:23`. - **Effort:** S ### BIO-017 — two PII guards have no executable test - **Control:** 8.29 (security testing in development); 5.13 - **Class:** **defect now** - **Severity: low** — both guards were verified correct by reading them; the finding is that nothing would catch a regression. - **Evidence (read):** - `apps/ssp/src/app/auth/application/session.store.ts` — **G1 holds on every path, verified:** `restore()` (`:12-21`) returns `{ bsn: '', naam }` and never reads a stored BSN; the constructor `effect()` (`:43-48`) writes only `{ naam: s.naam }`; `login()` (`:52-56`) sets the signal in memory only; `logout()` removes the key. There is no path that writes the BSN to `localStorage`. Identical in `apps/behandelportal/src/app/auth/application/session.store.ts`. - But agent 02's **TE-001** is right that the guard is untestable as written: `restore()` is module-private and reads `localStorage` in a field initializer. Per-file lcov (agent 02, from §3a): **LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13**. - `apps/ssp/src/app/shell/debug-state/mask.ts:13-25` — `redactProfile` is a pure, exported, directly callable PII-redaction function with **no spec** (agent 02's "missing test, not blocked test"). It redacts name, birthdate and address and masks the BIG-nummer; verified correct by reading. - **Baseline citation:** §3a `ssp/auth` · `bhp/auth` **42.9% line / 46.2% branch — jointly the worst line coverage in the frontend** (§8 ranking); **BL-009** (no coverage threshold is enforced anywhere, so nothing ratchets this). - **Remediation, minimal:** take agent 02's TE-001 seam (`parseStoredSession` in `auth/domain/session.ts`, which already has a spec file) and add the three cases — absent, non-JSON, wrong shape — plus one that asserts a stored `{"bsn":"…","naam":"…"}` yields `bsn: ''`. Add a five-line spec for `redactProfile`. - **Effort:** S (this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled — but the BSN assertion must be in TE-001's acceptance criteria, which today it is not). --- ## 4. Control area: cryptography (8.24) ### BIO-014 — nothing is encrypted at rest: document bytes, BSNs and the audit trail sit in a plaintext SQLite file - **Control:** 8.24 (use of cryptography) - **Class:** **production gate** - **Severity: high** — the file contains the highest-classification data in the system in one place, unprotected. - **Evidence (read) — what is at rest, and what class of data:** | Table | Contains | Class | | ----------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- | | `Documents` (`DocumentStore.cs:9-20`) | `byte[] Content` — the uploaded diploma / identity / language-proficiency scan | AVG art. 9-adjacent; identity documents | | `Documents.Owner` | BSN | AVG art. 87 national identifier | | `Applications.Owner` (`ApplicationStore`) | BSN | same | | `Applications.ZgwError` | may contain a BSN (BIO-010) | same | | `AuthzAudit.Resource` | contains a BSN today (BIO-008) | same | | `AuditEntries.Actor` | BSN (BIO-009) | same | | `Briefs` / `Briefs.ArchivedHtml` | the rendered letter, incl. name, BIG-nummer, address | personal data | - `Data/Db.cs:16-18` — `UseSqlite(ConnectionString)`, default `"Data Source=bigregister.db"`. No key, no `SQLCipher`, no page encryption. - `DocumentStore.cs:3-8` records the intent — "a real backend persists them to blob storage keyed by DocumentId" — so the shape is a known stand-in. - The file lands in the app's working directory and, under `docker-compose.yml`, on the host through the `./backend:/src:z` bind mount. **Verified: it is gitignored** (`backend/.gitignore:5`, `git check-ignore` confirms both copies), and `git ls-files` shows zero tracked `.db` files — so there is no committed-database finding. - No transparent-data-encryption, no filesystem-level requirement, and no key management exists anywhere in the repo. - Also verified and **not** a finding: no secrets are committed. `appsettings.json`'s `Zgw` block ships every credential field empty, `backend/openzaak/setup_configuration/data.prod.yaml` and `seeded.env` are both gitignored (`.gitignore:57`, `:60`), and `ZgwOptions.Secret`'s doc comment correctly states it is "Held only by the BFF, never the browser". `ZgwTokenProvider.cs` mints a short-lived HS256 JWT per call with no refresh token to store — sound for what it is. - **Baseline citation:** §2 size inventory — `backend/Data` (excl. Migrations) 1 697 lines, the largest backend folder after `Program.cs`; §7 Backend — the 7 static stores, "each opens a short-lived context via `Db.Create()` under its own lock". - **Remediation, minimal:** none for the POC. Checklist items: move document bytes out of the relational store to encrypted object storage keyed by `DocumentId` (the code already says this is the target); require encryption at rest for the database (managed-service TDE or SQLCipher); define key custody and rotation. **Prerequisite:** BIO-008/009/010 first, so the BSN is not in three places that do not need it before deciding what must be encrypted. - **Effort:** L (infrastructure), but S to write the requirement down and to stop widening it. ### BIO-015 — no transport security, no security response headers, Swagger and `AllowedHosts: *` unconditional - **Control:** 8.24 (cryptography in transit), 8.28 (secure coding — attack surface) - **Class:** **production gate** - **Severity: medium** - **Evidence (read):** `Program.cs:86-131` is the whole pipeline. It contains no `UseHttpsRedirection`, no `UseHsts`, no `UseAuthentication`/`UseAuthorization`, and no response-header middleware — so no `Strict-Transport-Security`, no `X-Content-Type-Options: nosniff`, no `Content-Security-Policy`, no `Referrer-Policy`. `app.UseSwagger(); app.UseSwaggerUI();` (`:121-122`) run in every environment, with no `app.Environment.IsDevelopment()` guard. `appsettings.json` sets `"AllowedHosts": "*"`. CORS (`:37-39`) allows only `http://localhost:4200` — which is tight, and worth noting is effectively unused since both the dev servers and `docker-compose.yml` proxy `/api` same-origin (`API_PROXY_TARGET`); the behandelportal's `:4201` is not in the list and does not need to be. - **`nosniff` in context:** the upload allow-list is `application/pdf`, `image/jpeg`, `image/png` only (`Domain/Documents/DocumentCategory.cs:20-22`, enforced at `Program.cs:216-217`), and `GET /uploads/{id}/content` serves `inline` only for pdf and `image/*` (`:235`). No script-capable type (SVG, HTML) can be uploaded, so the usual stored-XSS-via-inline-attachment path is **closed by the allow-list**. The content type is nevertheless client-declared rather than sniffed from magic bytes, which is why `nosniff` belongs on the checklist rather than being a finding today. - **Baseline citation:** **BL-003** (`Program.cs` 940 lines, file CC 78 — the one place every pipeline decision lives); §3c `Program.cs` 97.4% line / 84.8% branch. - **Remediation, minimal:** wrap Swagger in `if (app.Environment.IsDevelopment())` — one line, and it is a genuine attack-surface reduction with no POC cost. The rest are checklist items, most of which belong to the reverse proxy rather than the app. - **Effort:** S for Swagger; the rest is deployment configuration. --- ## 5. Control area: secure development (8.25, 8.28, 8.29) ### BIO-016 — what the security gates cover, and what they do not - **Control:** 8.25 (secure development lifecycle), 8.28 (secure coding), 8.29 (security testing in development and acceptance) - **Class:** **production gate** - **Severity: medium** - **Evidence (read) — `.github/workflows/ci.yml`, in full:** **Present, and genuinely blocking:** - `semgrep scan --config p/default --config p/csharp --metrics=off --error` (`:254-279`) — SAST on both sides, `--error` makes it a gate, telemetry off, prior findings triaged rather than suppressed wholesale. - `npm audit --omit=dev` (`:128`) — the shipped bundle must audit clean. - All actions pinned to full SHAs (`:32-33`, `:64`, `:70`, …) — supply-chain hygiene. - `permissions: contents: read` at workflow level (`:9-11`), plus per-ref concurrency cancel. - `npm run dep:check` (`:109`) — 11 `severity: error` architecture rules, **frontend only**. - `dotnet format --verify-no-changes` + `dotnet test --filter "Category!=Integration"` (`:199-204`) — 241 backend tests. - `api-client-drift` (`:281-317`) — the wire contract cannot drift unnoticed. **Absent:** - **No dependency vulnerability scan on the backend.** There is no `dotnet list package --vulnerable --include-transitive` step; `npm audit` covers only the frontend. The .NET dependency tree is unscanned. - **No secret scanning** (gitleaks/trufflehog). Today nothing is committed (verified in BIO-014), so this is prevention, not repair. - **No authorization regression suite as a gate.** `AuthzTests.cs`, `AuthzAuditTests.cs`, `WerkvoorraadTests.cs` and `StubIdentityProviderTests.cs` exist and run inside `dotnet test`, but nothing asserts the _set_ of gated endpoints — so an endpoint added without a gate (BIO-004's shape) fails no test. - **No backend architecture enforcement at all — BL-006.** `Domain/` purity holds by convention. The property ADR-0005 depends on ("ZGW shapes never leave `Zgw/`") and the property this file depends on ("authorization lives in `Authz`") are both review-maintained. - **No coverage ratchet — BL-009.** Nothing can regress-test a security fix by CI number. - No DAST, no container image scan, no SBOM. Reasonable omissions for a POC; listed so the production decision is explicit. - **Baseline citation:** **BL-006** (verbatim: "the backend has zero automated architecture enforcement … `Domain/` purity currently holds by convention") and **BL-009** (no coverage threshold is enforced anywhere). - **Remediation, minimal:** two cheap additions with real value here — (a) `dotnet list package --vulnerable --include-transitive` as a failing step in the `backend` job; (b) one endpoint-inventory test that enumerates the app's route table and asserts every route outside a small allow-list passes through one of the six authorization wrappers. (b) is the test that would have caught BIO-003, BIO-004 and BIO-005. - **Effort:** S for (a), M for (b). --- ## 6. Control area: change control (8.32) ### BIO-020 — the only deployment artifact in the repo builds development bundles - **Control:** 8.32 (change management); 8.25 - **Class:** **production gate** - **Severity: medium** — there is no release path, so there is no gate at which any of the production-gate items in this file would be checked. - **Evidence (read):** `docker-compose.yml` — the `api` service sets `ASPNETCORE_ENVIRONMENT=Development` and runs `dotnet run`; both `web` and `web-behandelportal` run `npx ng build … --configuration development --localize`, and the file's own header comment states why: "development config keeps `isDevMode()=true` so the dev tools render". The image is `mcr.microsoft.com/dotnet/sdk:10.0` / `node:24-slim`, and the header opens "dev-server images (not multi-stage prod builds) — this is a demo". **Consequence for every dev hatch in this file:** in the one containerised deployment the repo ships, `isDevMode()` is `true`, so `roleInterceptor`, `subjectInterceptor`, `medewerkerInterceptor`, `scenarioInterceptor` and the `⚙ state` debug panel are all live. That is correct for a demo and must not be mistaken for a production deployment. - **Baseline citation:** §2 size inventory (two apps + one backend, all deployed by this one file); **BL-006** (no automated enforcement that would distinguish the two). - **Remediation, minimal:** none for the POC. Checklist: a separate production compose/Helm artifact with `--configuration production`, `ASPNETCORE_ENVIRONMENT=Production`, and a release checklist that names this file's production gates. - **Effort:** M (out of this backlog) **Positive finding, recorded rather than ticketed.** Change control over the _business rules_ is genuinely strong and is the model the rest should follow: stamdata is config-as-code (ADR-0004), validated at build by `StamdataValidationTests`, with **no runtime write endpoint at all** — verified: `Program.cs:164` and `:173` are both GETs behind `StamdataAdmin`, and `libs/beheer`'s editor downloads a JSON file for a reviewed PR (`libs/beheer/src/infrastructure/stamdata.adapter.ts:16-20`, "There is no write method"). A bad reference-data edit fails CI, never production. The two sanctioned runtime-editable surfaces (`OrgTemplateStore`, `FeatureFlagStore`) both keep their catalog in code and fail closed on an unknown key (`Data/FeatureFlagStore.cs:56`) — but see BIO-007: their **writes are not audited**, which fails clause (4) of the four-part test agent 06's ADR-C-009 proposes. --- ## 7. Control area: input validation (8.26) **The server is the authority, and it is.** Verified against ADR-0001's rule ("the FE renders decisions, it does not recompute business rules"): `SubmissionRules.RejectPhoneChange` (`Domain/Submissions/SubmissionRules.cs:36-42`) re-validates the phone number server-side with the same normalisation the FE's `parseTelefoonnummer` applies, and says so; `DocumentRules.RejectUpload` (`Domain/Documents/DocumentCategory.cs:82-90`) authoritatively enforces the content-type allow-list and the size cap before any byte is stored; `Program.cs:208-217` validates multipart shape and required fields before reading the file; `Program.cs:367` validates document ownership on submit. `IntakePolicy.ScholingThreshold` is shipped to the FE as a _value_ for instant feedback and re-validated server-side — the config-value shape ADR-0001 prescribes. The 30 FE `parse*` boundaries (baseline §7) are defence in depth on the response direction, not a substitute; CLAUDE.md explicitly puts runtime DTO validation on every endpoint out of scope, and this pass does not reopen that. Two gaps, both narrow: ### BIO-019 — `GET /stamdata/{table}?peildatum=` 500s on unparseable input - **Control:** 8.26 - **Class:** **defect now** - **Severity: low** — admin-gated, and the failure is a 500 rather than a leak. Filed because an unhandled exception on a user-supplied string is exactly what 8.26 exists to prevent, and in a Development environment the exception detail is returned to the caller. - **Evidence (read):** `Program.cs:178` — `var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`. `DateOnly.Parse` throws `FormatException` on anything unparseable; there is no `TryParse`, no 400 path, and `.Produces` on the endpoint declares only 200/403/404. - **Baseline citation:** §3c `backend/Stamdata` 96.8% line but **71.7% branch** — named in **BL-005** as the second-weakest branch axis; this is one of the unentered branches. - **Remediation, minimal:** `DateOnly.TryParse(p, out var d) ? … : Results.Problem(statusCode: 400)`. - **Effort:** S ### Noted, not filed `GET /uploads/status?localIds=` splits an unbounded comma-separated list into a single `WHERE IN` query (`Program.cs:242-248`, `DocumentStore.ByLocalIds`). No cap. Real but negligible at POC scale; it belongs with BIO-018's "bound the client-supplied inputs" theme rather than as a ticket of its own. --- # Module sections Every module in scope gets a section. "No findings" is a result, not an omission. ## apps/ssp — auth **BIO-017** (the G1 guard has no executable test). **The guard itself holds on every path** — `restore()`, the persistence `effect()`, `login()` and `logout()` were each read and none writes the BSN to storage. No other findings: `DigidAdapter` is the faked login CLAUDE.md places out of scope, and `Session.bsn` never leaves the in-memory signal (verified: the one place that needed it, `subject.interceptor.ts`, deliberately does **not** reach into `SessionStore` — see BIO-012 for what it does instead). ## apps/ssp — registratie **No findings of its own.** It is the consumer side of **BIO-011**: `ui/admin-cases.page.ts:91` renders the unmasked BSN column the backend ships; the fix is server-side. `application/draft-sync.ts` and the five value objects (`domain/value-objects/`) do format validation only and never act as authority — correct per ADR-0001. `parseBsn` (`libs/shared/src/kernel/bsn.ts`) is checksum-only and says so. ## apps/ssp — herregistratie **No findings.** The scholing threshold arrives from the server as a config value (`domain/intake.machine.ts:52`) with the offline fallback ADR-0001 sanctions; nothing in this context touches PII, authorization or the network directly. ## apps/ssp — brief **BIO-012** (the three hand-written `fetch` adapters carry `?role=`/`?subject=` into production builds) and **BIO-006** (the client sends `X-Step-Up: 'true'` as a literal). The context is otherwise the best-behaved consumer of the decision-DTO pattern in the repo: `application/brief.store.ts:31,103-108` derives every gate from `BriefState.loaded.decisions` and states "this store never computes them itself" — verified true. ## apps/ssp — showcase, shell, root **BIO-017** (`shell/debug-state/mask.ts::redactProfile` has no spec; verified correct by reading). **The dev panel is genuinely gated and is not a finding** — `libs/shared/src/layout/shell/shell.component.ts:73,79` renders it only under `@if (isDev && debugPanel)` with `isDev = isDevMode()`, and `shell/debug-state/debug-state.component.ts:176` masks the BSN even there. The `showcase` context is a teaching page and reads every context by sanction; it introduces no data path. ## apps/behandelportal — auth **BIO-002** — this is where it lands. `app.config.ts:57-63` is the gate; `auth/infrastructure/medewerker.ts:14` is the fixed stand-in id. Also relevant and already owned by agent 06: `auth/ui/login.page.ts:31` logs a backoffice user in through DigiD with a BSN (ADR-C-004). ## apps/behandelportal — behandeling **No findings of its own.** Consumer side of **BIO-011**: `domain/werkvoorraad-item-view.ts:28` renders the unmasked BSN for every queue row while `domain/beoordeling.ts:26` correctly documents its own field as server-masked. Positively: `infrastructure/beoordeling.adapter.ts:87-97` rejects the payload at the parse boundary if `decisions.canBesluiten` is absent — deny-by-default at the wire, which is the right reflex. ## apps/behandelportal — shell, root **BIO-002** (the `app.config.ts` interceptor gate). No other findings — routing, providers and nav config only. ## libs/shared — domain **No findings.** `capability.ts`, `role.ts`, `feature-flag.ts` — 30 lines of type declarations with no executable statement (agent 02's verified correction to BL-004). The capability names are enforced at runtime by `parseMe`, which is exported and spec'd. ## libs/shared — application **No findings.** Both access-control primitives were read and are correct: `access.store.ts:34-37` is deny-by-default (`rd.tag === 'Success' && rd.value.includes(cap)`), and `whenReady()` (`:50-53`) exists specifically so the guard cannot read `can()` mid-load and deny an entitled user. `auth.guard.ts:22-47` (already moved here per agent 06's ADR-C-006) reads only `SESSION_PORT` and `AccessStore` and documents itself accurately as "the UX pre-gate. The backend re-enforces regardless (403)". **That claim was verified endpoint by endpoint for the admin surfaces and holds:** `/beheer/stamdata` → `StamdataAdmin` (`Program.cs:164,173`), `/beheer/zaken` → `CasesAdmin` (`:425,554`), `/beheer/audit` → `CasesAdmin` (`:566`), `/beheer/functies` → `FlagsAdmin` (`:592`), `/brief/huisstijl` → `OrgAdmin` (`:726,732,739,751,764,703`), `/aanvraag/:id` → `Beoordelen` (`:446,473`). Every capability the guard checks has a server-side twin. The exceptions are in `Program.cs`, not here: BIO-003 (a gate outside `Authz`), BIO-004 and BIO-005 (endpoints with no gate at all) — and none of those has a `capabilityGuard` claiming to front it. ## libs/shared — infrastructure **BIO-012** — `role.ts:23-31` and `subject.ts:23-29` are the ungated readers, and `subject.ts` is where a BSN reaches `sessionStorage`. Otherwise clean: `api-client.provider.ts:58` attaches the Idempotency-Key only when `method !== 'GET'`, and `:66`'s `retry({count: 2})` is GET-only. ## libs/shared — ui **No findings.** No network, no storage, no PII decision. The `masked-value` atom renders what it is given; the masking itself lives in `kernel/pii.ts`. ## libs/shared — layout **No findings.** `shell.component.ts:73,79` is the dev-panel gate and it is correct (see ssp/shell above). ## libs/shared — kernel **No findings, and this is the reference standard.** `bsn.ts` is a "parse, don't validate" value object whose doc comment correctly classifies a BSN as "art. 9 GDPR/AVG special-category data" and correctly scopes itself to format+elfproef ("identity is still faked in this POC"). `pii.ts`'s `maskTail`/`maskBsn` are pure, and the backend keeps a verified twin (`Program.cs:861-863`) so wire redaction and UI redaction agree. §3a: 96.4% line / 90.0% branch, §3b **100% reach** — the best-covered module in the repo. ## libs/shared — upload **No findings in this remit**, but two dependencies to record: the document bytes this module moves are the objects **BIO-004** serves without a check and **BIO-014** stores unencrypted. `upload.adapter.ts:216-217`'s server-side counterpart enforces the content-type allow-list authoritatively, so the FE's accept filter is UX only — the correct division. Agent 02's TE-005 (`uploadOutcome`) is an 8.26-relevant boundary; see the compliance-review section. ## libs/shared — testing **No findings.** Kept out of production by the `no-testing-in-production` dependency-cruiser rule (§6, 0 violations). ## libs/shared — environments **No findings.** `apiBaseUrl` only; no credentials, no keys. ## libs/beheer **No findings of its own.** It is the display surface for **BIO-008**: `ui/audit.page.ts:10-12` describes the trail as "data-minimised, no PII" and renders the `resource` column that today contains a BSN. `ui/feature-flags.page.ts:93` is the caller of the unaudited admin write in **BIO-007**. `infrastructure/stamdata.adapter.ts:16-20` is genuinely read-only, which is the positive ADR-0004 note in §6. ## backend/Program.cs **BIO-003, BIO-004, BIO-005, BIO-006, BIO-007, BIO-013, BIO-015, BIO-019.** Eight of the twenty findings land in one 940-line file, which is itself the observation: **BL-003** records it as "the single largest complexity concentration in the repo", and the six authorization wrappers (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`, plus the orphaned `IsAdmin`) sit 500 lines below the endpoints they gate, with nothing that enumerates which endpoint uses which. That is the structural condition under which BIO-003/004/005 exist, and it is why BIO-016's proposed route-table test is worth more than three individual fixes. ## backend/Domain **BIO-001, BIO-002, BIO-006** — all three live in `Domain/Authorization/`. The rest of the folder is clean and its design is right: `Authz` is a single source of truth where it is used, the same function both emits the decision flag and gates the mutation (`Authz.Decisions` ↔ `Authz.CanActOn` ↔ `BriefStore.Review`), and the four-eyes rule (`CanActOn(Approve, …) => principal.Role == Approver && ActingId(principal) != drafterId`) is a real segregation-of-duty control, correctly ordered Forbidden-before-Conflict. `CanBeoordelen(CallerIdentity)` is deliberately caller-kind-derived rather than role-derived and is the one capability that a forged `X-Role` cannot reach — the right instinct, and the reason BIO-002 fails closed in that direction. `Domain/` is EF-free and ASP-free (§7), though nothing enforces it (**BL-006**). ## backend/Data **BIO-008, BIO-009, BIO-014, BIO-018.** All four are storage-side data-handling items rather than logic defects; the store shape itself (static, `Db.Create()`-per-call, documented in `Data/Db.cs:6-12`) is out of scope here and is deliberately not challenged — agent 02 reaches the same conclusion from the testability side. ## backend/Zgw **BIO-010** only. Otherwise the strongest module in the backend for this pass: the ACL is complete (ADR-0005, fully conformed per agent 06), the client secret is BFF-only and never reaches the browser, tokens are short-lived and minted per call with no stored refresh credential, the inbound notification webhook fails closed on an unconfigured secret (`ZgwOptions.NotificatieAuthorization`: "Empty (the default) means every notification is rejected — an unconfigured secret must never mean 'accept anything'"), and the diagnostic handler logs no bodies and is opt-in. §3c: 98.1% line / **85.5% branch, the best on the backend**. ## backend/Contracts **BIO-011** — `Mappers.cs:74` is the single line that decides whether a cross-owner list ships a raw BSN, so the fix is one expression here rather than at each endpoint. No other findings: `Dtos.cs` contains no `Bsn` field by name (verified by grep) — the BSN travels only as `Owner`, which is exactly the field BIO-011 addresses. ## backend/Stamdata **BIO-019** only, and it is a low-severity parse gap. The module is otherwise the best change-control story in the repo — see the positive finding in §6. --- # Compliance review required **This is the mandatory flag. Agent 08 must carry every row into `99-backlog.md`, attached to the finding, not filed as a separate ticket.** These are findings from agents 02, 04 and 06 that touch a control area in §0. A flag is not a rejection: each of these should proceed, with a compliance acceptance criterion added to its definition of done. | Their ID | Their title (abbrev.) | Control touched | Why it is flagged, and what the added acceptance criterion must be | | ------------------- | ---------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **TE-001** | `SessionStore.restore()` reads `localStorage` inline | 5.13, 8.29 | The extracted `parseStoredSession` **is** the G1 PII guard. Ship it with an explicit assertion that a stored `{"bsn":…}` yields `bsn: ''` — see **BIO-017**. Without that assertion the ticket moves the guard without testing it. Lands twice (both apps). | | **TE-002** | Trust boundary hidden in a global-`fetch` method | 5.12, 8.26, 8.15 | The boundary guards a **PII reveal**. `reveal-bignummer.adapter.ts` is also the site of **BIO-012** (`X-Role` ungated) and **BIO-006** (`X-Step-Up: 'true'` literal). Fix all three in one touch of the file, or the next reviewer will re-open it. | | **TE-003** | `UploadTransport` port declared, concrete class injected | 5.12, 8.26 | The transport carries identity-document bytes. Introducing `UPLOAD_TRANSPORT` must not make it possible to substitute a transport in a production build — token factory only, no route/query override. | | **TE-004** | `createUploadController` injects + binds at call time | 8.26 | `planFileSelection` is the client-side accept/reject decision. It is **UX only**; the ticket must not let it read as authority — the server allow-list (`DocumentRules.RejectUpload`) stays the authority. State that in the moved function's docstring. | | **TE-005** | `xhrUpload` interprets responses inside an XHR closure | 8.26 | `uploadOutcome` is a trust boundary on an untrusted response. Also: the extraction must not move `parseError`'s ProblemDetails text into a place where it can be logged with a filename. | | **TE-006** | Blob-to-browser handoff inlined in 3 commands | 5.13 | The blobs are **rendered letters containing name, BIG-nummer and address**, and the stamdata download is an admin export. `BLOB_PRESENTER` must not gain any persistence or caching; recording fakes belong in specs only. | | **TE-008** | Brief transition rules live inside store methods | **9.4** | The five guards being extracted include the authorization ones, and `Authz.CanActOn` (the **four-eyes / SoD** rule) sits one line away at `BriefStore.cs:163`. Acceptance: `BriefRules` must **call** `Authz`, never re-implement it, and `tests/Domain/BriefRuleTests.cs` must cover approver == drafter. | | **CQ-002** | 2 read stores write without the fold; errors dropped | 8.15, 8.16 | One of the two is `AdminCasesStore.delete` — a **destructive admin action** whose failure is currently silent. The error surfacing this ticket adds is the FE half of **BIO-007**'s server-side audit gap. Ship them aware of each other. | | **CQ-003 / CQ-005** | `runSubmit` (write fold + idempotency mint) used for reads | 9.4, 8.26 | Splitting `runResult`/`runSubmit` narrows who mints an `Idempotency-Key`, which is the key **BIO-018** shows is an unscoped cache key. Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set. | | **CQ-004** | `FeatureFlagStore.set` skips the fold, drops the error | 8.15, 8.32 | A `flags:manage` admin write that fails silently **and** writes no audit row (**BIO-007**). This is the concrete case that fails clause (4) of agent 06's **ADR-C-009** four-part test. Fix the FE error and the BE audit row together. | | **CQ-006** | Read/write banner split abandoned in 5 of 7 sections | **9.4** | A large-diff, zero-semantic-change reordering across **all 48 endpoint mappings**, including every authorization wrapper call site. Acceptance: `AuthzTests`, `AuthzAuditTests`, `WerkvoorraadTests`, `OrgTemplateEndpointTests`, `StamdataEndpointTests` and `AdminCasesTests` all green, **and** a reviewer confirms each moved endpoint kept its gate. Land it alone, as CQ-006 already says. | | **CQ-007** | `GET /brief` creates a row | 8.26, 9.4 | A GET with a persisted side effect, auto-retried by the FE. Either fix is acceptable to compliance; the documentation-only alternative is **not** — a non-idempotent GET must be visible in the code, not only in a ticket. | | **ADR-C-002** | `libs/shared/src/upload/` does network outside `infrastructure/` | 5.12 | Moving the adapter that carries identity documents. Mechanical, but the acceptance criterion (deleting the depcruise carve-out) must not be met by widening a different rule. | | **ADR-C-004** | The `Principal` union never landed | **9.2, 9.4** | This is the natural home for **BIO-002**. Acceptance must include: the behandelportal's identity works in a **production** build, and `IIdentityProvider` can express "no identity". Landing `Principal` on the FE alone would close the ADR and leave BIO-002 open. | | **ADR-C-006** | Extract the actor-agnostic route guards to `libs/shared` | **9.4** | Already present in the tree at `libs/shared/src/application/auth.guard.ts` — verify the state before ticketing. Any future change to `authGuard`/`capabilityGuard` is an access-control change and needs the guard spec re-run for both apps. | | **ADR-C-009** | Generalise the runtime-editable-config exception | **8.32, 9.4** | Agent 06's proposed clause (4) is "writes are admin-capability-gated **and audited**". Today they are gated but **not audited** (**BIO-007**). Either the ADR amendment lands with BIO-007, or the amendment ratifies a control the code does not implement. | **Not flagged** (read and judged to touch no control in §0): TE-007, TE-009, CQ-001, ADR-C-001, ADR-C-003, ADR-C-005, ADR-C-007, ADR-C-008, ADR-C-010, ADR-C-011. --- # Pre-production compliance checklist The **production gate** items, as a checklist. None of these is a defect in the POC; every one must be true before this system holds real BSNs. Ordered by dependency, not by severity. **Identity and access (9.1, 9.2, 9.4)** - [ ] **Replace `StubIdentityProvider`** with a provider built from verified DigiD claims (zorgverlener) and verified employee-SSO/eHerkenning claims (medewerker). `X-Role`, `X-Subject`, `X-Medewerker`, `X-Rollen` and `X-Admin` are removed as inputs, not merely ignored. — **BIO-001** - [ ] **`IIdentityProvider` can express "no identity"**, and an unauthenticated request is rejected rather than defaulted. No code path may resolve a caller from a constant. — **BIO-002** - [ ] **The behandelportal has a non-dev identity path.** Verify by building both apps with `--configuration production` and confirming the backoffice cannot act as a citizen. — **BIO-002** (see also ADR-C-004) - [ ] **A startup assertion** fails the app in Production if the resolved `IIdentityProvider` is the stub. — **BIO-001** - [ ] **Row-level scoping** (PRD-0002 §5b) on every read that returns person data. Acceptance: a second seeded citizen cannot see the first's dashboard, notes, BRP address or diplomas. — **BIO-013** - [ ] **The PII-reveal capability comes from the app overlay, not the coarse role**, and is not held by the default role. — **BIO-006** - [ ] **Real step-up.** `X-Step-Up` is replaced by a server-verified assurance/recency attribute; no client may satisfy it with a constant. — **BIO-006** **Cryptography (8.24)** - [ ] **Encryption at rest** for the database, with documented key custody and rotation. — **BIO-014** - [ ] **Document bytes move to encrypted object storage** keyed by `DocumentId` (the code already names this as the target). — **BIO-014** - [ ] **TLS everywhere**: `UseHttpsRedirection` + HSTS at the edge, and no plaintext listener. — **BIO-015** - [ ] **Security response headers**: `X-Content-Type-Options: nosniff`, CSP, `Referrer-Policy`, and a real `AllowedHosts`. — **BIO-015** - [ ] **Swagger and the OpenAPI document are Development-only.** — **BIO-015** **Logging, monitoring and retention (8.15, 8.16)** - [ ] **Every authorization-relevant event is audited on the allow path too** — admin mutations, brief approvals/rejections, besluiten, PII reveals. — **BIO-007** - [ ] **No BSN in any audit row, log line or persisted error field**, enforced by a test that asserts on **values**, not column names. — **BIO-008, BIO-009, BIO-010** - [ ] **Audit retention, integrity and access** are defined: how long, append-only, who may read `/beheer/audit` (today it reuses `cases:manage`, which `Program.cs:565` already flags as a placeholder for a dedicated `audit:read`). - [ ] **Log shipping and alerting** — the audit trail is a SQLite table with no export path today. **Data protection (5.12, 5.13)** - [ ] **A DPIA** covering BSN, uploaded identity documents and the BIG register, with a documented lawful basis and retention schedule. Nothing in the repo covers this. - [ ] **Data minimisation on every list endpoint** — no unmasked BSN as a default field. — **BIO-011** - [ ] **Deletion / retention** for uploaded documents and the audit trail. `AdminDelete` exists; no retention policy does. **Secure development (8.25, 8.28, 8.29)** - [ ] **Backend dependency vulnerability scanning** in CI. — **BIO-016** - [ ] **Secret scanning** in CI. — **BIO-016** - [ ] **An authorization regression gate**: a test that enumerates the route table and asserts every route passes an authorization wrapper or is on an explicit allow-list. — **BIO-016** - [ ] **Backend architecture enforcement** (NetArchTest/ArchUnitNET) so `Domain/` purity, the "ZGW shapes stay in `Zgw/`" property (ADR-0005) and "authorization lives in `Authz`" are CI-maintained rather than review-maintained. — **BL-006** - [ ] **A coverage ratchet**, so a security fix can be verified as not regressed by CI. — **BL-009** - [ ] **Penetration test / DAST** before go-live, with BIO-004's object-level authorization and BIO-005's document linking as named test cases. **Change control (8.32)** - [ ] **A production build and deployment artifact exists** (`--configuration production`, `ASPNETCORE_ENVIRONMENT=Production`), separate from the demo compose file, and its release checklist references this list. — **BIO-020** - [ ] **Verify by build, not by reading**: in a production bundle, `?role=`, `?subject=`, `?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the three hand-written `fetch` paths. — **BIO-012** --- ## Summary | ID | Title | Control | Class | Sev. | Module | Effort | | ----------- | -------------------------------------------------------------------- | ---------- | --------------- | ------ | ----------------------- | ------ | | **BIO-001** | Backend trusts client-asserted identity headers in every environment | 9.2, 9.4 | production gate | high | BE/Domain+Program | S | | **BIO-002** | Production backoffice has no identity; default is the seeded citizen | 9.4 | production gate | high | bhp/auth+root, BE | S | | **BIO-003** | `X-Admin` is a second, unaudited gate outside `Authz` | 9.4, 8.15 | **defect now** | medium | BE/Program | S | | **BIO-004** | `GET /uploads/{id}/content` + `/uploads/status` have no authz check | 9.4, 5.12 | **defect now** | high | BE/Program+Data | S | | **BIO-005** | `POST /registrations` links arbitrary documents, unscoped | 9.4, 8.26 | **defect now** | medium | BE/Program | S | | **BIO-006** | Reveal capability on the default role; step-up is a client constant | 9.4 | production gate | medium | BE/Domain, ssp/brief | S | | **BIO-007** | Only denied decisions are audited; admin writes leave no trail | 8.15, 8.16 | **defect now** | medium | BE/Program | S–M | | **BIO-008** | BSN written into the authz audit trail's `Resource` column | 8.15, 5.12 | **defect now** | high | BE/Program+Data | S | | **BIO-009** | BSN is the `Actor` on every document audit row | 8.15, 5.12 | **defect now** | medium | BE/Data | S | | **BIO-010** | BSN reaches logs + a persisted field via the ZGW error path | 8.15, 5.12 | **defect now** | medium | BE/Zgw+Program | S | | **BIO-011** | Cross-owner lists ship unmasked BSNs; the detail masks | 5.12, 5.13 | **defect now** | medium | BE/Contracts | S | | **BIO-012** | `?role=` / `?subject=` reach production on 3 hand-written fetches | 5.13, 9.4 | **defect now** | medium | ssp/brief, shared/infra | S | | **BIO-013** | Seeded-citizen endpoints ignore the caller (no row scoping) | 9.4, 5.12 | production gate | medium | BE/Program | M | | **BIO-014** | No encryption at rest for bytes, BSNs and the audit trail | 8.24 | production gate | high | BE/Data | L | | **BIO-015** | No TLS/HSTS/nosniff/CSP; Swagger + `AllowedHosts:*` unconditional | 8.24, 8.28 | production gate | medium | BE/Program | S | | **BIO-016** | CI security gates: semgrep+audit present; 5 gaps | 8.25/28/29 | production gate | medium | repo (CI) | S–M | | **BIO-017** | Two PII guards have no executable test | 8.29, 5.13 | **defect now** | low | ssp/auth, ssp/shell | S | | **BIO-018** | `IdempotencyStore` unscoped by caller, no TTL, unbounded | 9.4, 8.26 | **defect now** | low | BE/Data | S | | **BIO-019** | `?peildatum=` 500s on unparseable input | 8.26 | **defect now** | low | BE/Stamdata | S | | **BIO-020** | The only deployment artifact builds development bundles | 8.32 | production gate | medium | repo (compose) | M | **Twelve defect-now findings** (BIO-003, 004, 005, 007, 008, 009, 010, 011, 012, 017, 018, 019 — BIO-006's step-up sub-item is counted inside its production gate), **eight production gates**. Fifteen of the twenty are effort **S**. **Modules with no findings of their own:** ssp/registratie · ssp/herregistratie · bhp/behandeling · libs/shared/{domain, application, ui, layout, kernel, upload, testing, environments} · libs/beheer. **Recorded as correct, so a later pass does not "fix" them:** the deny-by-default `AccessStore.can()` + `whenReady()` pair; `capabilityGuard`'s "UX pre-gate, backend re-enforces" claim, verified endpoint by endpoint for all six admin surfaces; `Authz.CanBeoordelen`'s caller-kind derivation (the one capability a forged `X-Role` cannot reach); the four-eyes rule in `Authz.CanActOn` with Forbidden-before-Conflict ordering; the `isDevMode()` gate on the debug panel and the interceptor chain; the ZGW secret never reaching the browser and the notification webhook failing closed on an unset secret; the upload content-type allow-list enforced server-side; stamdata having no runtime write endpoint at all; and `libs/shared/src/kernel/{bsn,pii}.ts`, which are the standard the rest should be measured against.