Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md
T
ehoandClaude Opus 5 176e5baef8 docs: BIO2 compliance pass + consolidated backlog (agents 07, 08)
Completes the pipeline's analysis phase. Agent 07 (BIO2/ISO 27002:2022,
control set stated as an assumption since none was supplied) produced 20
findings — 12 "defect now", 8 "production gate" — and agent 08 consolidated
all 47 findings across 00/02/04/06/07 into 33 tickets, 5 ADR-fixes and a
release checklist.

Two findings are live defects rather than refactoring candidates, both
verified directly:

- RB-01/BIO-004: GET /uploads/{documentId}/content takes only (string
  documentId) — no HttpContext, so no authorization is possible. It streams
  diploma and identity scans, protected by GUID unguessability alone, while
  DELETE on the same resource is owner-scoped.
- RB-02/BIO-008: Program.cs:674 concatenates the caller's BSN into the authz
  audit Resource column, which is persisted to SQLite and rendered by the
  admin audit page. Four doc comments claim that store holds no PII; the test
  cited as enforcing it asserts on column names, so a BSN inside a column
  called Resource is invisible to it.

07 also answered the handoff from 06: in a production behandelportal build no
X-Medewerker is sent, so StubIdentityProvider returns the seeded citizen. It
fails closed on backoffice capabilities but open on citizen-scoped ones,
including CanRevealBigNummer. Root cause is IIdentityProvider.Resolve
returning a non-nullable CallerIdentity — the interface cannot express "no
identity", so any provider must invent one.

08's gate was relaxed from all-seven to the four agents that ran; _status.md
records why 01/03/05 were skipped, and the backlog carries a "Coverage"
note naming what those skips leave unowned. It caught two errors in the
orchestrator's handoff: CQ-002 is not fixed (ApplicationsStore.cancel and
AdminCasesStore.delete still swallow errors -> RB-20), and CQ-004 shipped
with half its compliance criterion unmet (PUT /admin/flags/{key} writes no
audit row -> RB-07, which blocks signing ADR-C-009).

Both agents preserved a "verified clean — do not fix" list, so a later pass
does not re-spend effort on the controls that already hold.

Consolidation halted for human approval per its spec. No source file changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:52:13 +02:00

1129 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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<string, IResult>` 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 | SM |
| **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) | SM |
| **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.