# ASVS L2 + MASVS-L1 verification report

`asvs-l2.md` and `masvs-l1.md` are **mappings**: every control has a status and a citation. ASVS is a
**verification** standard, so a mapping alone does not support a level claim — the claim only holds
once each line item has been checked against evidence that exists and passes. This document is that
check: a dated record of *how* each class of control was verified, what the verification found, and
what is explicitly claimed as a result.

It deliberately does **not** restate the 301 control rows. The two checklists stay the single
per-control source of truth (every security-sensitive PR is already required to update the row it
touches), and this report is the point-in-time audit over them. A re-verification edits this file's
header, findings, and changelog — not 301 duplicated rows.

| | |
|---|---|
| **Pass** | #2 (full re-verification). Supersedes the Pass-1 header, claim and census: passes 1.1–1.29 were single-delta or milestone-gate records and never regenerated the figures above the changelog, which is the inconsistency issues #932/#939 fixed. |
| **Date** | 2026-09-18 |
| **Commit verified** | `47358eb4` (branch `feature/asvs-report-reconciliation`, based on `main`), release line v0.8.6. The Pass-1 figures were `6a7cb7a2` (branch `claude/issue-378-v0-6-1-6e589d`, v0.6.1); a full re-pass had not run between them. |
| **Standards** | OWASP ASVS 4.0.3 (V1–V14), OWASP API Security Top 10 (2023), OWASP MASVS 1.5.0 (V2–V7) |
| **Scope** | Backend (Go/Gin + SQLite), web frontend (React SPA), Android client, deployment artifacts (Docker/nginx/compose), CI |
| **Performed by** | Self-assessment (issue #378). Adversarial testing by parties who did not build the system was tracked as issue #511 and closed against a standing position (`asvs-l2.md` P8): no commissioned third-party pen test — a hobby project with no security budget — with the two-agent credentialed engagement (#860, Opus 4.8 + DeepSeek V4 Pro against a live Caddy-fronted server) as the external assessment, all findings filed and dispositioned. A signed commercial pen test is not deferred to a gate; it is not planned. |

## The claim

> **ASVS Level 2, with 23 documented exceptions.** **MASVS-L1, with 1 documented exception** (plus two
> L2 controls satisfied as a bonus, not as a level claim).

Stated plainly, without a silent downgrade: 194 of 257 ASVS control rows are `satisfied` with
verified evidence, 38 are `not-applicable` with a written reason, 2 are L3-only and out of scope, and
**23 are `partial`** — each naming its own gap. Every one of those 23 is enumerated in
[§7 Exception register](#7-exception-register). A reader who rejects any single exception should read
the claim as "L2 except that control", which is the point of enumerating them.

**This is a self-assessment.** It is produced by the project's own tooling and a maintainer reading the
code; it is not an independent, commissioned audit — see §1's "Performed by" row and §6's "what this
pass could not verify". The level rows in `asvs-l2.md`, `masvs-l1.md` and `SECURITY.md` carry the same
`self-assessed` qualifier so the claim is never read as third-party attestation.

The counts in this section cannot silently drift from the checklists: `cmd/citecheck` parses both
checklists and asserts the level claims and the census above against the parsed rows in
`TestVerificationReportClaimsMatchCensus` (issue #939), the same in-process run that gates citations. A
count change without an update to this page (or vice versa) fails the backend suite and the
`Docs & security-doc citations` job.

Nothing in either checklist is `fail`. That is a real property of this pass, not a definitional
dodge: `partial` here always means "the control is met in part and the shortfall is named", never
"unimplemented but softened".

### Status census

Generated from the checklists by `go run ./cmd/citecheck` (backend/). Since issue #939 the census is
also *asserted* against the checklists by `TestVerificationReportClaimsMatchCensus`, not merely pasted
here — the tool prints it (to a human) and the test fails if this table's totals or the level claims
diverge from the parsed rows.

| Chapter | satisfied | partial | not-applicable | out-of-scope |
|---|---|---|---|---|
| V1 — Architecture, Design and Threat Modeling | 34 | — | 4 | — |
| V2 — Authentication | 26 | 10 | 8 | — |
| V3 — Session Management | 15 | 2 | 1 | — |
| V4 — Access Control | 7 | 1 | 1 | — |
| V5 — Validation, Sanitization and Encoding | 21 | — | 5 | — |
| V6 — Stored Cryptography | 10 | 1 | 3 | 2 |
| V7 — Error Handling and Logging | 10 | 1 | 1 | — |
| V8 — Data Protection | 11 | 1 | 3 | — |
| V9 — Communication | 4 | — | 3 | — |
| V10 — Malicious Code | 4 | — | 1 | — |
| V11 — Business Logic | 5 | 2 | 1 | — |
| V12 — Files and Resources | 12 | 1 | 2 | — |
| V13 — API and Web Service | 7 | 2 | 2 | — |
| V14 — Configuration | 19 | 1 | 3 | — |
| API Security Top 10 (2023) | 9 | 1 | — | — |
| **ASVS total** | **194** | **23** | **38** | **2** |
| MASVS V2 — Data Storage and Privacy | 8 | 1 | — | — |
| MASVS V3 — Cryptography | 6 | — | — | — |
| MASVS V4 — Authentication and Session Management | 7 | — | 1 | — |
| MASVS V5 — Network Communication | 3 | — | — | — |
| MASVS V6 — Platform Interaction | 6 | — | 3 | — |
| MASVS V7 — Code Quality and Build Settings | 7 | — | 2 | — |
| **MASVS total** | **37** | **1** | **6** | — |

---

## 1. What "verified" means, per class of control

ASVS conformance is a claim about evidence, so the verification method has to differ by what the
evidence *is*. Five classes, five methods:

| Class | How it was verified this pass |
|---|---|
| **Code-cited** (`file:line`) | Two steps, because they fail differently. (a) *Resolution* — the path exists and the line range is inside the file: fully automated, `go run ./cmd/citecheck`, now a CI gate. (b) *Content* — the cited lines still say what the row claims: `go run ./cmd/citecheck -drift` produces the candidate list, then human review of every candidate. Step (b) is where this pass found almost everything (§4, F-1). |
| **Test-cited** (a `TestXxx` name, a `_test.go` file, a Kotlin `SomeTest.method`) | The identifier exists in the tree (automated, `citecheck`) **and** the suite it belongs to passes (§3). A named test that no longer exists is a gate failure, not a warning. |
| **Tool-cited** (SARIF, a CI workflow, a gate command) | The workflow file and step exist at the cited lines, the tool is a hard gate rather than report-only where the row claims it is, and its tier (PR / main-merge / nightly, per issue #578) is stated correctly. Inventoried in §3. |
| **Doc/position-cited** (a `docs/` section, one of the P1–P5 positions) | The referenced section exists and still says what the row says it says, and the position's stated revisit trigger has not fired. |
| **`not-applicable`** | Verified as a *structural* claim about the architecture, not an absence of effort: single process, no cloud IAM, no XML parser, RP-only OIDC, and so on. Each row carries its own one-line reason; this pass confirmed none of the 38 reasons has been invalidated by an architecture change (the trigger that would flip them). |

Two method notes worth writing down, because they bound how much this pass proves:

- **Resolution is exact; content is heuristic plus human — but both now gate.** `citecheck` can
  prove a citation points *somewhere real*; only a reader can confirm it points at the *right*
  thing. The drift heuristic narrows the line-range citations to a few dozen candidates by asking
  whether the prose leading up to a citation shares any vocabulary with the lines it cites. It has
  false positives by design (negative claims and pure-structure citations legitimately share no
  words with their target), so the accepted ones are written down with a reason in
  `docs/security/citation-drift.ignore` and CI fails on anything *not* in that file — and equally on
  a baseline entry that no longer matches, so dead suppressions cannot accumulate. Same
  ignore-list-with-justification shape as `.trivyignore`, `.grype.yml`, `zap/dast.ignore` and
  `schemathesis/schemathesis.ignore`. `citecheck -drift` remains the unfiltered human listing for a
  verification pass, which is exactly when an accepted suppression should be re-examined.
- **Your own edits are a drift source.** The gate proves in-bounds-ness, which survives almost any
  edit; the content check does not. Any change to a cited file — including the workflow that carries
  the gate, and including a merge from `main` — invalidates line numbers below the edit point
  silently. See §8 step 2.
- **Ambiguous basenames resolve permissively.** The checklists cite `auth.go:141-154` where the
  surrounding row makes clear whether that is `middleware/auth.go` or `carddav/auth.go`.
  `citecheck` accepts a line range that is valid for *any* candidate with that basename. The
  disambiguation is the reader's; the tool only rules out ranges that fit none of them.

### Reproducing the automated half

```bash
cd backend && go run ./cmd/citecheck        # the gate — this is what CI runs
cd backend && go run ./cmd/citecheck -drift # the human listing, for a verification pass
```

The gate exits 1 on any unresolvable citation, out-of-range line, off-legend status, empty evidence
cell, `satisfied` row with no citation, vanished test identifier, unaccepted drift candidate, or
stale entry in `citation-drift.ignore`. It runs on every PR as the `Docs & security-doc citations` job in
`.github/workflows/unit-tests.yml`, deliberately without a path filter: a citation is orphaned by
*moving code*, not by editing the doc, and `.github/filters.yaml` maps `docs/**` to nothing.

`-drift` lists **every** candidate, baseline included, and always exits 0. Use it during a pass:
re-reading the accepted suppressions is part of re-verifying, and the gate by construction stays
silent about them.

---

## 2. Automated verification: citations and structure

`citecheck` over `asvs-l2.md`, `masvs-l1.md`, and `threat-model.md`:

| Measured | Result |
|---|---|
| Path citations | 1,088 (821 + 112 + 155), of which 404 carry a line or line range |
| Test-identifier citations | 131 (Go `TestXxx` and `TestFamily_*`, Kotlin `SomeTest.method`) |
| Control rows parsed | 301 (257 ASVS + 44 MASVS) |
| Citations that do not resolve | **0** |
| Line ranges outside their file | **0** |
| Rows with an off-legend status or empty evidence | **0** |
| `satisfied` rows citing nothing | **0** (13 before Pass 1 — §4, F-2) |
| Drift candidates from `-drift` | 45, every one reviewed and accepted with a reason in `docs/security/citation-drift.ignore`; 0 unaccepted (Pass 1 found 74 moved ranges — §4, F-1) |
| Cryptographic importers inventoried | 31, of which 18 are V6-accounted and 13 carry a justified `docs/security/crypto-surface.ignore` entry; failing in both directions |
| Deliberate non-file citations | 2, allowlisted with a reason in `cmd/citecheck/main.go` (`google-services.json`, `/.well-known/security.txt`) |

---

## 3. Automated evidence inventory

The suites and scanners that stand behind the `satisfied` statuses, with what each actually proves.
Tiering is issue #578's: **PR** = blocks a pull request, **main** = runs on merge to `main`,
**nightly** = scheduled.

### Suites run for this pass

| Suite | Result | Scale |
|---|---|---|
| `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...` | pass (exit 0), 96 packages | 4,770 `Test` + 8 `Fuzz` + 4 `Benchmark` functions across 667 test files |
| `cd backend && go test ./cmd/citecheck/` | pass | the gate above, plus the table-driven failure-class cases in `cmd/citecheck/main_test.go` |
| `cd frontend && npx tsc --noEmit && npx vitest run` | pass (exit 0) | 1,842 tests across 207 test files |
| `go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/unit-tests.yml` | pass | the workflow carrying the gate; the exact version CI pins |

Not re-run in this pass, because they need a live stack rather than a checkout, and each is a CI gate
in its own right: Playwright E2E (`e2e-tests.yml`), Android instrumented E2E (`android-tests.yml`, 42
`@Test` methods), Schemathesis + `bolacheck` + `schemagate` (`schemathesis.yml`), ZAP DAST + `zapgate`
+ `dastcanary` (`zap-dast.yml`). Their evidence value for this pass is that they are hard gates whose
last runs on `main` are green, not that they were re-executed here.

### Security-relevant checks, by what they prove

| Evidence | Proves | Tier |
|---|---|---|
| `backend/routes/authorization_matrix_test.go` (#371) | Every route registered on the live router × six personas (unauth / owner / intruder / admin / disabled / expired). Enumerated from `router.Routes()`, so a new route with no declared authorization row **fails**, and a declared row with no route fails as stale. This is the strongest single piece of ASVS V4 evidence in the repo. | PR |
| `backend/routes/authorization_matrix_credentials_test.go` (#566) | The same route enumeration for the two credential types that are **not** a cookie JWT: a CardDAV/CalDAV Basic-auth credential (401 on every `/api/v1/*` route; on the DAV surface it reaches only its own collections), a `carddav`-scoped API token (403 on every REST route), and a `full`-scoped API token (matches the cookie-JWT `owner` verdict on every route). Reuses `buildTable` + the completeness guard, and carries its own guard over the registered DAV routes. | PR |
| `backend/cmd/bolacheck` (#369) | Cross-account BOLA sweep over the real HTTP surface: user B creates one resource of each of 13 entity types, user A attempts GET/PUT/DELETE on every one. 2xx or 5xx fails the run. | PR (schemathesis.yml) |
| Schemathesis + `backend/cmd/schemagate` (#369) | Spec-derived request fuzzing from `openapi.yaml`: 500s on malformed input, auth-protected operations returning data unauthenticated. `schemagate` is the gate, not the scanner's exit code. | PR |
| ZAP DAST + `zapgate` + `dastcanary` (#368) | Dynamic scan of the running app, gated on High/Medium with an ignore-list-with-justification. `dastcanary` is a deliberately-vulnerable sibling server whose planted findings prove the scanner and gate are actually working — a green scan cannot be a silently broken scan. | nightly |
| `backend/.semgrep/mycorrhizal-traps.yaml` (#370) | Five custom rules for this codebase's own recurring bug classes, each with a must-match fixture in `.semgrep/tests/`. `mycorrhizal-query-string-auth-material` is the standing enforcement behind ASVS 8.1.3 / 8.3.1 / 13.1.3. | PR (sast.yml) |
| `backend/errors/fail_secure_realdb_test.go`, `errors/middleware_test.go` (#366) | A forced DB failure yields a typed code and no raw driver error; a panic yields a generic 500 with stack and panic value logged server-side only. | PR |
| `backend/services/oidc_attack_matrix_test.go`, `controllers/oidc_attack_matrix_test.go` (#412) | `state`/nonce/PKCE binding, authorization-code replay, issuer/audience/`azp` validation, and the account-mix-up invariant (an OIDC identity may never authenticate as another local account because an attacker controls an email address). | PR |
| `backend/services/contact_sync_hostile_input_test.go` (#512) | A hostile remote CardDAV update cannot wipe or downgrade a secret-sensitivity custom field or relationship edge. | PR |
| `backend/controllers/contact_share_matrix_test.go` (#555) | Sensitivity filtering holds across an actual account boundary, asserted against the stored share payload rather than the API response; selecting a sensitive section cannot imply the opt-in. | PR |
| `backend/routes/session_lifecycle_test.go`, `middleware/auth_lifecycle_test.go` (#372) | `TokenVersion` revocation: password change / reset / admin reset each kill pre-existing sessions. | PR |
| `backend/httputil/fetch_test.go`, `services/webhook_ssrf_test.go`, `webhook_ssrf_integration_test.go`, `notification_service_test.go` (#373) | SSRF guard on the live webhook delivery path and the push path, not just the dialer in isolation. | PR |
| `backend/models/audit_chain.go` + `cmd/audit-verify` (#381) | Tamper-evidence: each `AuditEvent` commits `SHA-256(prev_hash ‖ content)`, a `BEFORE UPDATE` trigger rejects edits, and the operator can verify the chain out-of-band. | PR + operator |
| `database/concurrent_write_test.go` | `_txlock=immediate` — the DSN flag without which concurrent writes 500 with `database is locked` in under 5 ms. | PR |
| CodeQL, Trivy (misconfig + secret), zizmor, actionlint, shellcheck, golangci-lint (gosec + bodyclose), govulncheck, Dependency Review | SAST/SCA/workflow-security hard gates. | PR |
| Signed SBOM (`syft-sbom.yml`), Grype, TruffleHog git-history | Supply-chain second opinions. | main |
| Mutation testing (Stryker frontend, gremlins backend — issue #915), full-length fuzz, CIS container hardening | Test-suite quality and container baseline. | nightly |
| **`backend/cmd/citecheck` (this pass)** | Every citation in the security checklists resolves, and no `satisfied` row cites nothing. | PR |

---

## 4. Manual verification records

The four manual checks issue #378 requires, plus what each found. "Manual" here means a human read
the code; where a script produced the candidate list, the script is named and its output was reviewed
item by item.

**Pass #2 note.** Audit A was re-run against the current tree (current counts below). Audit B is no
longer a hand-read at all — since issue #610 it is the test `controllers/cookie_flags_test.go`, which
passed in this pass's full suite and is the standing evidence. Audits C and D were spot-checked at the
Pass-2 commit: the two citations that had drifted (the JWT-secret boot validation and the
`COOKIE_SECURE` boot refusal) were re-located, and the rest still resolve to what their rows claim.
The per-control citations themselves remain continuously gated by `cmd/citecheck`; this section is the
narrative record, not the source of truth.

### A. Handler scoping — no IDOR

**Method.** Static sweep of all **68** non-test controllers: for every function containing a GORM query
verb (`First`/`Find`/`Take`/`Last`/`Where`/`Delete`/`Updates`/`Save`/`Create`/`Count`/`Scan`/`Pluck`/
`Model`/`Raw`/`Exec`), check whether the function body — comments stripped, so a comment mentioning
`user_id` cannot pass for scoping — carries an ownership token (`user_id`, `userID`, `VCardUID`,
`currentUserID`, or a scoped loader helper). Every function without one was then read.

**Result (re-run for Pass #2).** 246 controller functions contain a DB query. The raw verb match flags
**11** as lacking an in-function ownership token; one is a false positive (`photo_controller.go`'s
`saveImage`, where `os.Create` matches the `Create` verb), and the other ten are correct:

| Site | Why it is correct |
|---|---|
| `admin_user_controller.go` `ListUsers` | Admin-only route, deliberately cross-user; gated by `middleware/admin.go` and covered by the authorization matrix's `admin` persona. |
| `user_controller.go` `LoginUser` | Looks up the account being authenticated. There is no caller identity to scope by yet — unscoped by definition. |
| `device_grant_controller.go` `ExchangeDeviceGrant` | The device token itself is the credential: the grant is found by its hash and the user by the grant's own `UserID`. This is the session-minting path, so there is no caller identity yet. |
| `timeline_controller.go` `applyDateBounds` / `applyCursor` | Query-builder helpers. Each receives a `base` query that every caller has already scoped. |
| `attachment_controller.go` `DeleteAttachment` | Loads the row through `loadOwnedAttachment` (the scoped loader helper) and deletes that loaded row. |
| `contact_share_controller.go` `usernamesByID` | Helper resolving display names for account IDs a caller already scoped. |
| `webhook_controller.go` `latestDeliveryHealth` | Helper over webhook IDs a caller already scoped. |
| `system_event_controller.go` `ListSystemEvents` | Admin-only operational telemetry, deliberately cross-user; carries no user content. |
| `system_status_controller.go` `systemStatusDatabase` | Diagnostic-only PRAGMAs (`sqlite_version()`, `journal_mode`); no user rows. |

**Verdict: no IDOR found.** This is a static complement to the runtime evidence, which is the
stronger of the two: `authorization_matrix_test.go` probes every registered route with an `intruder`
persona and fails on a route with no declared authorization row, and `bolacheck` sweeps 13 entity
types cross-account over real HTTP.

### B. Session cookie flags

**Method.** In Pass #1 this was a hand-read of every `SetCookie` call site (script-extracted, including
multi-line calls, with the governing `SetSameSite` resolved per site). Since issue #610 the evidence is
`controllers/cookie_flags_test.go`, which drives the real flows and reads the `Set-Cookie` surface out
of the responses instead — so it does not depend on a call-site count staying still.

**Result (test, Pass #2).** `controllers/cookie_flags_test.go` passed in this pass. The Pass-1 read
found 18 call sites across `user_controller.go`, `two_factor_controller.go` and `oidc_controller.go`;
the current tree has 12 `SetCookie` call sites (`user_controller.go` 7, `two_factor_controller.go` 3,
`oidc_controller.go` 1, plus `device_grant_controller.go` 1 from #722's session exchange), which the
response-driven test covers without any table edit. The Pass-1 properties hold:

- **`HttpOnly`: true at every site.** No site passes `false`; there is no JS-readable session cookie.
- **`Secure`: `cfg.CookieSecure` at every session-cookie site** (`auth_token`, `id_token`,
  `2fa_pending`, and their clears), with boot refusing the `FRONTEND_URL=https://` +
  `COOKIE_SECURE=false` combination (`config/config.go:844-849`).
- **`SameSite`: `Strict` on every session cookie**, tightened from `Lax` by issue #392.
- **`SameSite=Lax` on exactly the transient OIDC handshake cookies**, correctly: they are read on a
  top-level cross-site navigation back from the identity provider, which a `Strict` cookie would never
  be attached to — `Strict` here would break every OIDC login. They are additionally path-scoped and
  short-lived.

**One deviation found**, filed as issue #605: `oidc_client` and the handshake-cookie clears
hardcoded `Secure=true` instead of `cfg.CookieSecure`. That is *stricter* than configured, so it is
not a confidentiality gap — but on the supported plain-HTTP deployment a browser rejects the cookie
outright, so the Android client hint is never stored and the handshake cookies are never actively
cleared. Functional inconsistency, fails closed; recorded in row 3.4.1. **Fixed in #605.**

**Becomes a test (issue #610).** This audit is no longer a hand-read enumeration that has to be
redone every verification pass: `controllers/cookie_flags_test.go` drives every flow that mints or
clears a cookie (login, login+2FA, logout, password change, 2FA management, OIDC login start, OIDC
callback) against a real migrated schema and enumerates the `Set-Cookie` surface from the responses,
asserting `HttpOnly`, `Secure=cfg.CookieSecure`, and `SameSite` against a declared per-name policy
table. The table is exhaustive in both directions — an observed cookie with no declared row fails, a
declared row never observed fails — and the whole suite runs twice (once `CookieSecure=true`, once
`false`). Rows 3.4.1–3.4.3 cite it instead of a list of call sites.

### C. Cryptography, and the absence of unauthenticated modes

**Method.** Read every cryptographic call site and every route registration.

| Checked | Found |
|---|---|
| Password hashing | bcrypt at `bcrypt.DefaultCost` (10) through one shared `HashPassword` (`services/user_service.go:16-27`). Passwords over 72 bytes are **rejected** (`ErrPasswordTooLong`), not silently truncated — the bcrypt cap is explicit. See P1 for why not Argon2id yet. |
| JWT algorithm | The verifier pins the HMAC family before the key callback returns the secret: `token.Method.(*jwt.SigningMethodHMAC)` (`middleware/auth.go:66-71`). That is what defeats `alg: none` and RSA→HMAC confusion. The pin is family-wide rather than HS256-exact; HS384/HS512 would also verify, and all three take the same symmetric secret, so there is no downgrade to reach. Row 3.2.4 previously overstated this as HS256-exact and has been made precise. |
| Signing secret strength | Enforced at boot in four ordered checks — non-empty, ≥ 32 bytes, not a known published placeholder, minimum Shannon entropy (`config/config.go:695-714`). A long-but-published secret is as forgeable as a short one, which is why the placeholder check exists alongside the length floor. |
| Symmetric encryption | AES-256-GCM only — authenticated as it encrypts — for TOTP/integration credentials (`services/credential_crypto.go:33-58`, key HKDF-SHA256-derived from `JWT_SECRET_KEY`) and the at-rest field envelope (`backend/atrest`, single wrapped DEK, see P4). No unauthenticated mode is reachable by a caller. |
| Weak primitives | No MD5/DES/RC4/ECB/Blowfish anywhere. The tree's single `crypto/sha1` import is HIBP's own k-anonymity wire format, annotated at the import (`services/hibp_service.go:6`). SHA-256 is used only for non-reversible one-time token digests. |
| Random | `crypto/rand` for TOTP secrets and recovery codes (`services/twofactor.go:4,107`); bcrypt supplies its own per-hash salt. No `math/rand` in a security path. |
| Unauthenticated route surface | **15 registrations**, enumerated from `routes/routes.go`: `/health`, `/health/live`, `/health/ready` (the deep/liveness/readiness split, issue #421 — all secret-free, status + reason strings only); the two `.well-known` CardDAV/CalDAV redirects; `/auth/oidc/config` plus `login`/`callback` (registered only when OIDC is enabled); `register`; `login`; `login/2fa`; `logout`; `check-password-strength`; `password-reset/request`; `password-reset/confirm`. Every one that touches credentials carries `AuthRateLimitMiddleware`. Against 241 `protected.` + 9 `admin.` + 9 CardDAV/CalDAV Basic-auth routes. |
| Any way to turn auth off | **None.** No `DISABLE_AUTH` / `SKIP_AUTH` / `INSECURE_*` switch exists in any non-test Go file; `AuthMiddleware` is applied at the group level, so a route is either inside `protected`/`admin` or is one of the 15 above. The authorization matrix's `unauth` persona asserts this for every route rather than trusting the grouping. |

**Becomes a test (issue #612).** The "closed cryptographic surface" claim this audit read by hand is
now pinned by `cmd/citecheck`'s crypto-surface gate: it enumerates every non-test Go file importing
`crypto/*`, `golang.org/x/crypto/*` or a JWT/signing library and requires each to be cited by a V6
row in `asvs-l2.md` or to carry a justified entry in `docs/security/crypto-surface.ignore`,
failing in both directions (a new unaccounted call site, a declared one that stops importing
crypto). It runs in the same `Docs & security-doc citations` job as the rest of citecheck, so a new call
site fails the build at the moment of introduction rather than at the next verification pass.

### D. Error paths do not leak internals

**Method.** Read the error envelope and the last-resort handler, then searched every non-test Go file
for a response body carrying a raw `err.Error()` or an equivalent unbounded error string.

**Result.** The envelope is `{error:{code,message,details}, request_id, timestamp}`
(`errors/middleware.go:13-24,67-86`); internal errors are generic text; the panic-recovery middleware
logs the panic value and stack server-side and returns a generic 500
(`errors/middleware.go:27-44`). Both are pinned by tests (#366).

**One exception found**, and it is the only site in the backend that bypasses the envelope: the
self-service "test my notification channel" endpoint reports a diagnostic string rather than an
envelope error (`notification_controller.go:167-172`). That is defensible — the endpoint exists so a
user can find out why *their own* ntfy/Gotify/push target rejected the message, and a generic message
would make the feature useless.

The outbound path was then traced to the end, because "unbounded error from an HTTP client" reads
like an SSRF oracle and it is worth being exact about whether it is one. **It is not, on the default
configuration.** `WEBHOOK_BLOCK_PRIVATE_URLS` defaults to `false` (`config/config.go:148`), so
`postNotificationJSON` skips its private-address pre-flight and `clientFor` returns the unguarded
client (`services/notification_service.go:836`, `services/webhook_service.go:64-70`); save-time
validation checks only the scheme (`middleware/validation.go:182-194`). That is the documented
opt-in-per-service position (row 5.2.6 / API7), and it is deliberate: pointing a self-hosted app at
an ntfy instance on your own LAN is the intended use, so defaulting the block on would break the
common case. The caller therefore already chose the target and already learns its reachability from
the returned status; the error text grants no capability they lack.

The genuine leak is the inverse, and only in the hardened configuration: with the flag **on**, the
guarded dialer returns two distinct sentinels — `ErrWebhookUnreachable` and
`ErrWebhookPrivateAddress` (`services/webhook_service.go:29-30`, `httputil/safedial.go:27-47`) — and
both were echoed verbatim, so an operator who turned the flag on to declare internal targets
off-limits got an endpoint that reports which rule a target tripped. Thin (it confirms "the address
you supplied is private", about an address the caller supplied) but the wrong direction. **Fixed by
issue #606**: the echoed string is now capped at 256 bytes and, with the flag on, all three guard
sentinels collapse to one neutral "not reachable under this instance's outbound policy" message,
while the full diagnostic still goes to the server log — so the endpoint stays useful on the default
configuration and stops distinguishing rejection rules under the hardening flag (pinned by
`notification_controller_test.go` `TestNotificationConfig_TestNtfy_ErrorTruncated`,
`TestNotificationConfig_TestNtfy_PrivateAddressCollapsedWhenGuarded`,
`TestNotificationConfig_TestNtfy_UnresolvableCollapsedWhenGuarded`).

**Re-checked for issue #421** (the `/health` → `/health/live` + `/health/ready` + deep-`/health`
split). All three are unauthenticated and build their own JSON rather than going through the error
envelope, so each was walked for the same "raw `err.Error()` / unbounded string in the body"
pattern. They pass by construction: every failing facet logs the underlying error / path / host /
`operational_check_results.detail` server-side (`logger`) and returns a fixed category string —
`"database read failed"`, `"unreachable"`, `"cannot read migration state"`, `"attachments directory
is missing"`, `"the last run reported failed"`. Migration version *numbers* and scheduled-job
*names* are returned deliberately (both are already public — `git` tags, open-source job registry —
and are the point of the endpoint); table names, row counts, absolute paths, the operator's SMTP
host and OIDC URL, and raw dial errors are not. Guarded by
`controllers/health_endpoints_test.go`'s `TestDeepHealth_ResponseBodyLeaksNoInternals` and the
path-free assertions in the readiness-filesystem tests.

---

## 5. Per-chapter verification notes

The Pass-1 per-chapter manual read, with the counts refreshed for Pass #2 and the citations that had
drifted re-located. The Pass-2 automated checks (`citecheck`, `-drift`, the full suite) cover every
chapter; the notes below record the manual read the counts depend on.

| Chapter | Verified |
|---|---|
| **V1** Architecture | The threat model (#377) is current — its §5 `SameSite=Lax` error was corrected in Pass #1 (F-3), and Pass #2 added the operational/environmental-failure actor group (#930) and corrected the claim drift #931. CI inventory in 1.1.1 re-checked against the workflows and their #578 tiers, and `citecheck` added. |
| **V2** Authentication | Manual audit C. The 10 `partial` rows are the largest cluster in the checklist and are all deliberate product positions (no view-password toggle, no notification on self-service change) — none is an unimplemented control. |
| **V3** Session Management | Manual audit B (now the test `controllers/cookie_flags_test.go`). 3.2.4 made precise about the algorithm pin. |
| **V4** Access Control | Manual audit A, plus confirming the authorization matrix still fails on an undeclared route (that property, not the current pass/fail, is what makes it evidence). |
| **V5** Validation | Confirmed the raw-SQL sites are parameterized: FTS search (`search_service.go:228-305`), the graph CTE (`graph_traversal.go:96-131`), and the export grouping query, whose only interpolated values are compile-time table/column constants (`export_controller.go:105-119`). |
| **V6** Stored Cryptography | Manual audit C. 6.2.5 and 6.2.7 were `satisfied` with no citation at all and now cite the actual call sites plus the gosec/CodeQL enforcement (F-2). |
| **V7** Error Handling | Manual audit D; found and documented the one envelope bypass. |
| **V8** Data Protection | 8.1.3 and 8.3.1 were uncited assertions about absence and now cite the Semgrep rule that continuously enforces them (F-2). |
| **V9** Communication | TLS boundary re-confirmed at `docs/deployment.md:32`; HSTS wiring (`security_headers.go:43-46`, wired `main.go:512`). |
| **V10** Malicious Code | 10.3.2's SRI claim now cites why SRI is moot here (no external origin in the CSP) rather than asserting it (F-2). |
| **V11** Business Logic | The 2 `partial` rows are product decisions (per-user content quotas, business-activity monitoring) — unchanged, restated in §7; the alerting row (11.1.8) closed in #940. |
| **V12** Files and Resources | 12.3.6 now cites the lockfiles and the absence of `os/exec` in non-test packages rather than asserting it (F-2). |
| **V13** API | 13.1.3 now cites the Semgrep rule; 13.2.6 now states the deliberate absence of application-layer signing (F-2). API9 stays `partial` — no endpoint-inventory doc. |
| **V14** Configuration | The heaviest drift cluster: CORS, trusted proxies, and the boot validators had all moved within `main.go`/`config.go` (F-1). All re-located and re-read. |
| **API1–API10** | API1's evidence (the strongest claim in the file — "every handler AND-scopes") re-verified by manual audit A and the matrix/bolacheck pair. |
| **MASVS V2–V7** | Citations re-located after the Android tree moved (F-1); `deepLinkRoute`/`parseOidcReturn` re-read to confirm the strict scheme+host+path validation the PLATFORM-2/3 rows claim. STORAGE-5's login-screen gap is unchanged and remains the single MASVS exception. |

---

## 6. Findings

| # | Finding | Severity | Disposition |
|---|---|---|---|
| **F-1** | **48 distinct citations (74 occurrences) pointed at code that had moved.** Every one resolved to a real file with an in-bounds line range, so nothing flagged them: `main.go:191-209` cited for the CORS allowlist actually landed in the scheduler; `unit-tests.yml:175-177` cited for govulncheck landed in the fuzz step; `config.go:359-364` and `:375-380`, cited for the CORS and `COOKIE_SECURE` boot checks, landed in the at-rest-key comments the #380 work inserted above them. Also here: `webhook_service_test.go` (a file that no longer exists) and `errors/middleware.go:111-113` (a 112-line file). | High for the *checklist's* credibility; no code defect | **Fixed on this branch.** All 74 corrected and re-verified. `citecheck` now gates resolution on every PR, and `-drift` is the standing review queue for the content half. |
| **F-2** | **13 rows were `satisfied` with no citation whatsoever**, contradicting the checklist's own stated promise ("No row is left `satisfied` without a citation"). All 13 were negative controls — "no password expiry", "no KBA", "no CDN", "no plugin system" — where there is no `file:line` for a thing that does not exist, so they had been left as bare assertions. | Medium: unverifiable rows in a verification document | **Fixed on this branch.** Each now cites the artifact that proves the absence — the model/migration that has no such column, the Semgrep rule that fails a PR reintroducing it, the CSP that admits no external origin, the lockfiles. `citecheck` now fails a `satisfied` row that cites nothing. |
| **F-3** | **`threat-model.md` §5 stated the session cookie is `SameSite=Lax`.** It has been `Strict` since issue #392. A factual error, not a stale line number. | Medium: the threat model understated an implemented control | **Fixed on this branch**, including the reason `Lax` is retained for the OIDC handshake cookies only. |
| **F-4** | **OIDC handshake cookies hardcode `Secure=true`** on the `oidc_client` set and all four clears, while their siblings use `COOKIE_SECURE` (manual audit B). Stricter than configured, so not a confidentiality gap — but on the supported plain-HTTP deployment the browser rejects the cookie, so Android OIDC silently falls back to the web redirect and the handshake cookies are never actively cleared. | Low, fails closed | **Filed as issue #605** (v0.6.2). Recorded in row 3.4.1. |
| **F-5** | **The test-notification endpoint echoes an unbounded upstream error** to the client (manual audit D) — the only backend site bypassing the error envelope. Defensible as a self-service diagnostic (the user needs to know why *their own* ntfy/Gotify target refused the message), but unbounded. Traced to the end during review: **not** an SSRF oracle on the default configuration, because `WEBHOOK_BLOCK_PRIVATE_URLS` defaults off and pointing a self-hosted app at your own LAN is the intended use (row 5.2.6's opt-in-per-service position) — the caller already chose the target and already learns its reachability from the status code. The real, thin leak runs the other way: with the flag *on*, the guarded dialer's two distinct sentinels are echoed verbatim, so the hardening flag makes this endpoint marginally more informative rather than less. | Low | **Fixed by issue #606** (v0.6.2). Echoed string capped at 256 bytes; with the flag on, the guard sentinels collapse to one neutral outbound-policy message client-side while the full diagnostic stays in the server log. Remains a documented exception in row 7.4.1 (bounded diagnostic, not the envelope) by design. |
| **F-6** | **Row 3.2.4 overstated the JWT algorithm pin** as HS256-exact; the code pins the HMAC family. The security property (rejects `alg: none` and asymmetric confusion) is unchanged, but the row claimed more precision than the code has. | Low | **Fixed on this branch**: the row now states the family pin and why no downgrade is reachable. |
| **F-7** | **This report's own header, claim and census were stale and self-contradictory** (issues [#932](https://github.com/DrewBrunning/mycorrhizal-crm/issues/932)/[#939](https://github.com/DrewBrunning/mycorrhizal-crm/issues/939)): they still carried Pass-1's `26` exceptions / `191` satisfied / census total `186`, while a fresh `citecheck` parses `23` / `194`; §7's heading said 26 partial rows while its own intro said 23; the report claimed the census "cannot drift" although nothing asserted it; and the level claim was not qualified as a self-assessment. A verifier could not evaluate #500's security-posture criterion from the page. | Medium: a verification document that contradicts itself is not evidence | **Fixed on this branch.** Header/claim/census regenerated from `citecheck`; §7 corrected to 23; the level claim qualified `self-assessed`; and the census + level claims are now *asserted* against the checklists by `TestVerificationReportClaimsMatchCensus` (issue #939) instead of pasted by hand. |
| **F-8** | **Threat-model claim drift** (issue [#931](https://github.com/DrewBrunning/mycorrhizal-crm/issues/931)): the sync-path row still called the reconcile path an open gap after #512 closed; "every `uses:` is SHA-pinned" ignored the documented `slsa-github-generator@v2.1.0` exception; the cosign identity pin omitted `promote-rc.yml`; the governance-drift cadence was called "nightly" when it is weekly and token-gated; and the import actor row cited a test that proves a different vector. | Low–Medium: claims both weaker and stronger than reality | **Fixed on this branch** (same PR): every row corrected, the import row re-pointed to the parser fuzzers and adversarial-import suite, and the operational/environmental-failure actor group added (issue [#930](https://github.com/DrewBrunning/mycorrhizal-crm/issues/930)) so #500's ten scenarios each have a row. |
| **F-9** | **The `routes` package is flaky under load or `-count>1`** (found re-running the suite for the v0.9.0 pass): `AuthRateLimitMiddleware` is a process-wide in-memory token bucket, so the package's tests — which all drive the live router against one process — accumulate auth attempts until a later auth-hitting test gets a **429** instead of its expected status (`TestDeviceGrantRoutes_EnrollThenExchangeThroughLiveRouter`, `device_grant_route_test.go:82`). One of two clean `go test ./...` runs failed; `go test ./routes/ -count=5` fails reproducibly. Test hygiene, not a product defect — the limiter is meant to be process-wide — but it can be a false red on the backend suite, and therefore the release gate, under contention. | Low: a flaky test, not a control shortfall | **Filed as issue [#1186](https://github.com/DrewBrunning/mycorrhizal-crm/issues/1186)** (v0.9.0): reset the limiter between `routes` tests. |

No finding in this pass required flipping a control from `satisfied` to `fail`. F-1 through F-3 and
F-6 through F-8 were failures of the *documentation* to remain true; F-4 and F-5 are real code findings,
both low-severity and both failing closed. F-5 has since been fixed by issue #606 (§4 D, row 7.4.1).

Three further issues came out of asking what keeps this report true rather than out of the audit
itself, and are tracked in §9: **#608** (fold the gate and the re-verification obligation into the
release workflow), **#609** (semgrep rule for an outbound client bypassing the SSRF dialer), and
**#610** (pin audit B's cookie-flag enumeration as a test).

### What this pass could not verify

Stated so the claim is not read as stronger than it is:

- **Content correctness beyond the drift queue.** 360 line-range citations were checked for
  resolution; the ones the drift heuristic did not surface were not each re-read line by line. A
  citation whose target moved *and* whose new neighbourhood happens to share vocabulary with the row
  would survive.
- **Runtime behaviour of the E2E/DAST tiers.** Verified as "green hard gates on `main`", not
  re-executed here (§3).
- **Anything a self-assessment structurally cannot establish.** No adversary tried to break this
  system *as part of this pass* — the honest framing for what §1–§8 verify is "we verified our own
  evidence", not "we were tested". Adversarial testing was done separately: the #860 engagement
  (two independent LLM agents, credentialed, against a live Caddy-fronted server) is the project's
  external assessment, with its own recorded limitations — no independent human expertise, no
  signed report, Android/MASVS out of scope, and an enumerated not-tested list. Issue #511 is
  closed against the standing position in `asvs-l2.md` P8: a commissioned commercial pen test is
  not planned and no pre-1.0 gate is held for one.

---

## 7. Exception register

"L2 with 23 documented exceptions" is only meaningful if the exceptions are enumerable. They are.

### The eleven written-down positions

Full reasoning lives in `asvs-l2.md` § Documented positions; the revisit trigger is what matters for
re-verification.

| | Position | Revisit when |
|---|---|---|
| **P1** | bcrypt cost 10, not Argon2id (satisfies NIST 800-63B as written) | A password-hash version field + rehash-on-login path exists, making migration a code change rather than a forced reset for every user |
| **P2** | No crypto-agility abstraction; bcrypt/JWT called directly | A second algorithm is actually adopted (Argon2id, EdDSA sessions, external KMS). An abstraction with one implementation is dead weight |
| **P3** | HIBP breach check opt-in, off by default | A hosted multi-tenant deployment mode exists, where the operator already accepts outbound-call tradeoffs on users' behalf |
| **P4** | FTS-indexed columns stay plaintext; single wrapped DEK; lost key = lost data | Search moves off SQL-trigger-driven FTS5, or key escrow becomes a product requirement |
| **P5** | Backup confidentiality and retention are the operator's boundary | The project ships a managed backup destination |
| **P6** | Update-availability check opt-in, off by default (#650) | A hosted multi-tenant deployment mode exists (same trigger as P3) |
| **P7** | Android biometric session resume is client-side; server side is a revocable device grant, not a "remember me" token (#722) | The device grant grows any capability a fresh session JWT does not already have |
| **P8** | External assessment is the two-agent #860 engagement; no commissioned commercial pen test is planned or gated (#511) | The project's nature or funding materially changes — a hosted mode, a sponsor, or a security contributor with time for an independent pass |
| **P9** | Bidi/zero-width/confusable characters in contact names preserved verbatim; display-spoofing defense is a frontend concern, not data-layer stripping (#945) | A maintained Go confusables-detection library becomes available, or the frontend adds rendering-layer defenses that would make data-layer stripping worth reconsidering |
| **P10** | Attachment EXIF/GPS stripped for JPEG/PNG at ingest; HEIC is a named, untouched gap (#945) | A HEIC-capable metadata-editing library becomes available |
| **P11** | Outbound email CRLF hardening (explicit `To` rejection, defensive `Subject` strip) is defense-in-depth, not a response to a reachable gap — no current caller passes anything but a validated address / fixed subject (#945) | A caller ever needs to pass a non-`user.Email` recipient or a non-fixed subject |

### The 23 ASVS `partial` rows

Grouped by why each is short, because the groups have very different meanings.

**Deliberate product positions (11)** — the control is understood and not wanted as ASVS states it:
2.1.1 (length floor 8 + 50 bits entropy, per 800-63B, not 12 chars), 2.1.2, 2.1.12 (no
view-password toggle), 2.1.7 (P3), 3.4.4 (no `__Host-` prefix while plain-HTTP LAN deployments are supported), 6.2.4
(P2), 8.3.5 (read-auditing is a privacy/performance choice), 11.1.3, 11.1.7, 12.1.3 (per-user
quotas decided and enforced opt-in since issue #950 — the residual is that bulk-ingestion paths are
bounded per request, not yet against the cumulative quota), 14.4.2.

**Missing UI or surface, mechanism present (4)**: 2.2.3 and 2.5.5 (notification exists on the
recovery path, not on self-service change), 4.3.1 (2FA available to all, not *enforced* for admins),
API9 (no endpoint-inventory doc; `openapi.yaml` is maintained and drift-checked).

**Partial mechanism (6)**: 2.3.2, 2.6.2, 2.8.2, 2.8.5 (TOTP reuse is detected, rejected and logged
since issue #873 — no proactive owner notification, the 2.2.3 / 2.5.5 gap), 3.5.2, 13.2.5.
*(1.6.1 and 1.6.3 — "rotation works, no standalone runbook" — closed by issue #509's
`docs/security/incident-response.md`.)*

**Known small gaps with an obvious fix (2)**: 7.2.2 (no distinct access-denied event), 13.1.5
(content-type rejection + correct status). *(8.1.1 and 8.2.1 — `no-store` on `/api/` responses —
closed by issue #872; 11.1.8 — alerting on lockout spikes — closed by issue #940's distributed
credential-stuffing signal.)*

### The 1 MASVS `partial` row

**STORAGE-5** — the login screen's password field masks and autofills correctly but keeps default
keyboard options, so it lacks the `KeyboardType.Password` IME-learning signal that register /
forgot-password / settings fields have. The one field where a password is typed most often.

MASVS also carries six positions of its own (`masvs-l1.md` § Documented positions), of which two —
P1 certificate pinning and P3 root/SafetyNet detection — were re-evaluated and kept declined under
issue #507, and two — P4 Room cache encryption and P6 screenshot/tapjacking protection — were
resolved in the affirmative and are now `satisfied`.

---

## 8. Re-verification procedure

A re-pass is a diff against this file, not a rewrite. In order:

1. `cd backend && go run ./cmd/citecheck` — must exit 0. Any failure is a citation to fix before
   anything else; the rest of the report means little on top of broken citations. This is no
   longer only a per-PR check: it is `release_gate: true` in `.github/release-gates.json` (polled
   on the release commit) and the REL-06 release workflow (`.github/workflows/release.yml`) runs
   it directly as a hard gate, so a release cannot be cut on a broken citation (issue #608).
2. `cd backend && go run ./cmd/citecheck -drift` — read every candidate, **including the ones
   `citation-drift.ignore` already accepts**: a verification pass is exactly when a standing
   suppression should be re-justified or deleted. Correct the range, or accept it with a reason.
   This is the step that found F-1.

   **Run this step last, after every other edit in the pass, and then run it again.** Your own
   changes move lines too. In this pass, the `unit-tests.yml:175-177` → `:201-203` correction for
   govulncheck was made *before* the `docs-citations` job was inserted into that same file, and the
   40 lines of new job pushed govulncheck to `:241-243` — re-breaking a citation that had just been
   fixed, in the very commit that added the gate. The exact gate stayed green throughout (the range
   was still in bounds); only the drift pass saw it. Merging `main` into the branch has the same
   effect and needs the same re-run.
3. Run the suites in §3 and record the results, including scale, so a shrinking suite is visible.
4. Redo the four manual audits in §4. A/B/C/D are cheap: the scripts and one-liners behind them are
   described inline, and each produced a candidate list of single digits.
5. Re-read the `not-applicable` rows against the current architecture. They are written-down
   decisions about a single-process, self-hosted, no-cloud, RP-only-OIDC system; an architecture
   change is what flips them, and nothing else will.
6. **Re-examine §9's surface table for categories that did not exist last pass.** The enforced rows
   take care of themselves; this step exists for the row that is not in the table yet. Ask what
   kinds of security-relevant surface this release added — a new client, a new outbound integration,
   a new persistence target, a new authentication path — and for each, either name the mechanism
   that fails when the next one is added, or file the issue that will build it. A new category with
   neither is the gap this whole section exists to prevent.
7. Re-check every documented-position revisit trigger in §7 (`P1`–`P11`).
8. Update the header (pass number, date, commit), the census (from `citecheck`), §6 findings, and add
   a changelog row (§10). If a mechanism in §9 has been superseded, update §9 to describe what
   actually runs, and delete what it replaced. **#608 did the first such supersession** (2026-09-10):
   the per-milestone citation checkbox and the re-verification obligation both moved into the REL-06
   release workflow — §9's "Every release" tier and the CI-credential row below reflect that.

### The re-verification obligation, and where it now lives (issue #608)

The dated ASVS/MASVS claim needs a *forcing function* so it does not silently rot between
milestones. As of #608 that function is mechanical and lives in exactly one place, the REL-06
release workflow (`.github/workflows/release.yml`):

> Before it pushes the tag, `release.yml` fails the release if
> `docs/security/asvs-l2-verification-report.md`'s §10 changelog carries **no new row** since the
> previous release tag (`git describe --tags --abbrev=0 … HEAD`, then a diff for an added
> `| N.N | … |` line). The only escape is dispatching with `ack_asvs_current=<reason>`, which
> downgrades the block to a `::warning::` and records the reason in `release-metadata.json`.

This is #608's option 1 ("the strongest option") with option 2 as the named, recorded escape
hatch. It does **not** attempt to judge whether the re-verification was *adequate* — only that
one happened and is dated. The adequacy check is this procedure, run by a human, cited by the row.
The 16 hand-maintained gate-issue checkboxes and the `milestone_gate.md` standing criterion that
duplicated this are retired (`.github/ISSUE_TEMPLATE/milestone_gate.md` now points here instead of
restating it).

## 9. Keeping this true between passes

A verification report is a photograph. What stops it becoming a *historical* photograph is three
mechanisms at three different cadences, deliberately unequal in cost.

**Every PR — automated, no human in the loop.** The `Docs & security-doc citations` job
(`.github/workflows/unit-tests.yml`) runs `citecheck`, unfiltered by path. It fails on a citation
that stops resolving, a line that falls out of range, a vanished test identifier, a `satisfied` row
that cites nothing, an unaccepted drift candidate, or a stale entry in
`docs/security/citation-drift.ignore`. This is what keeps §2 true continuously rather than at
audit time, and it is the reason a re-pass is an hour instead of a rebuild.

**Every milestone — nothing hand-maintained (as of #608).** This tier *was* 16 gate-issue
checkboxes (#531–#543 plus #500/#503/#525) plus a `milestone_gate.md` standing criterion, each
asserting the citation job was green on the merge commit. #608 retired all of it: the `citecheck`
gate now runs unconditionally per-PR, is `release_gate: true` (polled on the release commit), and
is a direct hard step in `release.yml`. The template points at §8 instead of restating the
obligation. Nothing here depends on someone remembering this issue existed.

**Every release — a full re-pass, now with a mechanical forcing function.** The three release
gates (#500 `0.8.0`, #503 `0.9.0`, #525 `1.0.0`) require the ASVS/MASVS claim to be re-verified
against the *shipped* code, with a dated §10 changelog row as the citation. As of #608 that is no
longer only a checkbox: `release.yml` refuses to push the tag if §10 carries no new row since the
previous release tag (escape hatch: dispatch with `ack_asvs_current=<reason>`, recorded in
`release-metadata.json`). Re-running the whole pass every *milestone* would be disproportionate and
would get skipped; letting a published claim go unverified from v0.6.1 through 1.0.0 is the failure
this tier exists to prevent. #525 had no security criterion at all before this pass — it would have
shipped the 1.0.0 stability contract on a claim last checked thousands of commits earlier.

**The blind spot that leaves — and its calendar backstop (issue #949).** Every mechanism above is
event-triggered, so if the release cadence stalls the published claim ages with no warning at all —
the v0.6.1 → 1.0.0 span this section exists to prevent, one stalled cadence away from repeating.
A weekly advisory job (`.github/workflows/asvs-staleness.yml`, backed by `backend/cmd/asvsstale`)
alarms when this report's newest header or §10 date is older than 90 days and points back at §8.
It is a staleness *reminder*, deliberately not a re-pass: §9's closing argument holds that a full
calendar audit would fire on the wrong trigger and be closed with "looked, seemed fine", so the
re-verification itself stays a release obligation.

**Every release also re-asks the new-surface question (issue #953).** A final release records a
per-release adversarial delta: one row in [`adversarial-deltas.md`](adversarial-deltas.md) for each
security-relevant surface class its diff touched (`route`, `persistence`, `outbound-client`,
`auth-path`), gated by `backend/cmd/adversarialdelta` in `release.yml` with the same recorded
acknowledgement escape. The same workflow stage emits a machine-readable residual-risk statement
(open accept-with-reason items, dependency-exception expiry, ASVS/MASVS exception counts) into
`release-metadata.json` via `backend/cmd/residualrisk`. This is §8 step 6's question asked at every
release rather than only at the milestone, so a new surface *class* no mechanical check anticipated
is caught by the release that added it.

One thing about that model is worth being honest about: **none of the three cadences sees
genuinely new surface.** They prove existing claims still hold; they cannot notice that something
was added that *deserves* a row. What covers what today:

| New surface | Enforced today? |
|---|---|
| A route | **Yes.** `routes/authorization_matrix_test.go` enumerates from the live router and fails when a new route has no declared authorization row. Self-maintaining. |
| A new authentication path (a route that mints a session) | **Yes.** `routes/session_minting_route_gate_test.go` recovers the ordered middleware chain of every route from gin's live routing tree (`router.Routes()` exposes only the final handler) and asserts, against a declared `sessionMintingRoutes` table that fails in both directions: every session-minting route carries both `AuthRateLimitMiddleware` and `EnforceMinClientVersion` ahead of its handler (rate limiter outermost), and **no other route carries `EnforceMinClientVersion`** — so a new auth path built to the #692 pattern is caught until it is declared, and the floor spreading to a non-minting route is caught too. A companion test drives the four routes through the real `RegisterRoutes` wiring with a floor configured and asserts a below-floor client is refused `403 CLIENT_NOT_SUPPORTED` before any handler runs (guarding against the middleware being neutered while still present). `AuthRateLimitMiddleware` was given a distinct closure so the chain walk can tell it from the API/CardDAV limiters. Built by issue **#840**, filed by the v0.6.10 gate. |
| An outbound HTTP client | **Yes, in two layers.** (1) The semgrep rule `mycorrhizal-unguarded-outbound-dialer` (`backend/.semgrep/mycorrhizal-traps.yaml`, issue #609, run by `sast.yml`) fails at introduction on any `http.Transport` literal whose `DialContext` is not `httputil.SafeDialContext` — the tree-wide mechanical gate. (2) INT-01 (#464) added `backend/integrations/matrix_test.go`: `TestEveryOutboundClientIsClassified` enumerates every file under `backend/services/` opening an outbound client and fails on one with no classification row; `TestSSRFClaimsMatchSource` fails when a row claims a guarded posture its source does not back up. The one gap neither layer covers is a client built **inside a library**, where there is no literal to match and (before INT-01) no row to demand a posture — exactly the OIDC case (`go-oidc`'s internal client). INT-01's manual classification caught it and INT-02 (#465) closed it (`newOIDCHTTPClient`, `OIDC_BLOCK_PRIVATE_URLS`); a future library-internal client is caught by `TestEveryOutboundClientIsClassified` demanding a row, whose `SSRF` field then forces the decision. |
| A cookie | **Yes.** `controllers/cookie_flags_test.go` drives every flow that mints or clears a cookie against the real migrated schema and enumerates the `Set-Cookie` surface from the responses, asserting `HttpOnly`/`Secure=cfg.CookieSecure`/`SameSite` per name against a declared table that fails in both directions (an undeclared cookie observed, a declared one never observed) — run twice, once `CookieSecure=true` and once `false`. Built by issue **#610**. |
| An entity or table | **Yes.** `controllers/delete_cascade_coverage_test.go` enumerates every table from the real migrated schema and requires a declared deletion bucket (`go-cascade-user`/`go-cascade-contact`/`fk-cascade-user`/`exempt`), failing on an unclassified table and on a stale declaration; it asserts `fk-cascade-user` tables really carry an `ON DELETE CASCADE` FK to `users`, rejects a contact-scoped table relying on a cascade from the soft-deleted `contacts` row (trap 6), and behaviorally verifies `DeleteUser`/`deleteContactAssociations` empty every declared table. Built by issue **#611**. |
| A crypto call site | **Yes.** `cmd/citecheck`'s crypto-surface gate enumerates every non-test Go file importing `crypto/*`, `golang.org/x/crypto/*` or a JWT/signing library and requires each to be cited by a V6 row in `asvs-l2.md` or to carry a justified entry in `docs/security/crypto-surface.ignore` — failing in both directions (a new unaccounted call site, and a declared one that stops importing crypto). Lands in the existing `Docs & security-doc citations` job, so it fails the build at the moment of introduction. Built by issue **#612**. |
| A persistence target for instance data | **Partly.** `v0.6.2` added several: `system_events` (#424), `job_runs` (#391), `import_runs` (#651), `storage_samples` (#652) and `alert_states` (#428), plus the `webhook_deliveries` retention window (#622). All are system-generated operational/telemetry records, admin-only (or per-user non-sensitive), hard-delete, with a retention knob (`SYSTEM_EVENT_RETENTION_DAYS`, `JOB_RUN_RETENTION_DAYS`, `STORAGE_SAMPLE_RETENTION_DAYS`, `WEBHOOK_DELIVERY_RETENTION_DAYS`), and are recorded in `data-retention-lifecycle.md` (§3, §16–§20). Free-text fields are sanitized and length-capped (row 7.3.1; `import_runs` stores counts only, no messages). They are outside the cascade-coverage concern above (no user-data parent, nothing cascades into them — `import_runs` is the one exception, explicitly swept by `DeleteUser` in `admin_user_controller.go` and asserted by #611's behavioral sweep). No mechanical check yet asserts a *new* diagnostic/telemetry table gets a retention-lifecycle row — folded into #611's schema-driven coverage scope. |
| A privileged CI credential (repo-write outside PR review) | **Partly.** `release.yml` (REL-06, #499) mints a GitHub App token to push the release commit + tag directly to `main`, bypassing branch protection — the first non-human writer to `main` — and, since ADR 0021 (#1162), no longer holds `actions: write` — nothing is dispatched: the checks are composed through `release-validate.yml`, which is read-only at the top level and grants write scopes only where GitHub's static nested-permission validation requires them — `contents`/`pull-requests`/`actions` read on the member call-jobs, plus `security-events: write` on the three SARIF-uploading scan call-jobs and on the jobs that call the composer (`release.yml`'s `validate`, `docker-publish.yml`'s `release-gate`), since a nested job's declaration must be granted at every caller above it; the scans' upload *steps* are gated off when composed (`github.event_name != 'workflow_call'`), so the scope is inert in a composed run and only exercised by each scan's native push/PR/schedule run. `promote-rc.yml` (RC-02, #446) mints the same App token to commit the final schema fixture to `release/vX.Y.0` and merge that branch back into `main` on promotion; it is also `workflow_dispatch`-only with no PR path, and it pushes the final `v*` tag with `GITHUB_TOKEN` (not the App token) precisely so it cannot re-trigger `docker-publish.yml`. Constrained by: the App's own installation scope; `permission-contents: write` on the minted token (nothing else); a single `workflow_dispatch`-only workflow with no PR-triggered or `push`-triggered path to it; the App being the only non-human entry on `main`'s branch-protection / tag-ruleset bypass lists; and the full composed gate battery (citecheck, releasegatecheck, every `release_gate:true` check and release-tier suite via `release-validate.yml`, the ASVS-row check) running before any write, so a compromised dispatch still cannot publish past a red gate. Since #508 the bypass lists are **reviewable**: `.github/rulesets/*.json` commits the desired ruleset state (bypass actors included), and `.github/workflows/governance-drift.yml` diffs it against the live rulesets weekly, so adding a second bypass entry surfaces either as a PR diff or a drift warning. No mechanical check *asserts* the set is exactly {Admin, release App}, or that a second such credential or bypass entry does not appear — that judgement sits with the milestone gate (the "no new class of security-relevant surface" checkbox) plus the drift review. |

The pattern worth generalising from the one row that *is* enforced: the authorization matrix is strong
evidence because it derives its subject list from the running system and **fails on an undeclared
member**, in both directions. Every future "is this still true?" check here should be built that
shape — #609, #610, #611, #612 and #840 are all written to that spec on purpose.

### Why not a recurring audit issue

The obvious alternative — schedule a periodic "look for new surface" issue — was considered and
rejected for the rows above, because it fires on a calendar rather than on the event that matters.
It finds a new unguarded client weeks after merge, assigned to someone without the context, with no
forcing function; a periodic issue can always be closed with "looked, seemed fine". Converting each
row to a check that fails *at the moment of introduction* is strictly better, and is what #609/#610/
#611/#612 do.

What a calendar genuinely cannot be replaced for is the row that does not exist yet: a surface
*class* nobody has thought of — a new client platform, a new persistence target, a new auth path.
No mechanical check can enumerate categories that have not been invented. That judgement is carried
by the milestone gate instead of a schedule, because the person closing a gate knows what that
milestone just added, and someone opening a quarterly reminder does not. It is the last standing
criterion in `.github/ISSUE_TEMPLATE/milestone_gate.md`.

That criterion has now been exercised once, which is the only evidence that it works. Closing the
v0.6.1 gate (#531) asked the question of this milestone and found two answers: the new outbound
integration (HIBP, #561) was already covered by the outbound-client row above, and the two new
cryptographic call sites (#380, #381) landed against the one row that named neither a mechanism
nor an issue. Issue **#612** is that gap, filed by the gate rather than by a scheduled audit —
which is the behaviour this design predicted, on the first attempt.

The one case where a schedule *is* right — risk that changes with time rather than with code
(dependency CVEs, expiring certificates) — is already covered by the nightly tier (Grype, Trivy,
govulncheck).

## 10. Changelog

| Pass | Date | Commit | Claim | Findings |
|---|---|---|---|---|
| 1 | 2026-08-26 | `6a7cb7a2` | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | 6 (F-1…F-6): 4 documentation defects fixed on the branch, 2 code findings filed as issues #605 and #606. No control flipped to `fail`. |
| 1.1 | 2026-08-27 | (see PR) | ASVS L2 with **29** documented exceptions; MASVS-L1 with 1 | Not a full re-pass — a single-row delta. Issue #509 added `docs/security/incident-response.md` (operator incident-response + credential/key-rotation runbook, rotation procedures exercised against a real build). The stated gap for **1.6.1** and **1.6.3** was "no standalone rotation doc"; both move `partial → satisfied` in `asvs-l2.md`. §7's "Partial mechanism" group and the headline exception count are Pass-1 figures and reconcile fully at the next release re-pass; the running count is 29. |
| 1.2 | 2026-08-27 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | Not a full re-pass — issue #606 (finding F-5) landed: the test-notification endpoint's echoed diagnostic is now capped at 256 bytes and, under `WEBHOOK_BLOCK_PRIVATE_URLS`, the SSRF guard's sentinels collapse to one neutral message client-side while the full error stays in the server log (§4 D, row 7.4.1). The row remains `satisfied` with the same documented exception (a bounded diagnostic instead of the envelope) — no count changes. |
| 1.3 | 2026-08-27 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | Not a full re-pass — issue #389 added an opt-in Prometheus `GET /metrics` endpoint. It is a **new authenticated operational route**, not an unauthenticated one: registered only when `METRICS_TOKEN` is set, gated by a constant-time bearer check, exposing bounded-cardinality counters with no log lines or per-user data. §4 audit A's "**15 registrations**" unauthenticated-route count is **unchanged**. Row 1.7.2 notes the surface; `crypto/subtle` in the new handler is a written-down exception in `crypto-surface.ignore` (comparison helper, not a primitive choice). No control flipped. |
| 1.4 | 2026-08-28 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | The v0.6.2 milestone-gate closure (issue #532). Not a full re-pass — records the milestone's surfaces for §9's "new class" question: the new authenticated admin observability routes (`/admin/system-status` #388, `/admin/diagnostics` #423, `/admin/job-runs` + `/job-runs/health` #391, `/admin/notification-health` #422, `/admin/subsystem-health` #427, `/admin/error-aggregation` #426) are covered by the §9 route row's authorization matrix; the update-availability check (#650) is a new outbound client covered by the §9 SSRF rule (#609); the new `import_runs`/`job_runs`/`storage_samples`/`alert_states` tables and the webhook-delivery retention job (#622) are covered by the §9 entity/table row (#611) plus `data-retention-lifecycle.md` §3/§16–§20. Two gate-closing fixes landed: migration failures now name the failing version and file and record a `migration_failed` system event (`backend/database/migrate.go`, pinned by `TestMigrationFailureIdentifiesMigrationAndRecordsEvent`), and export failures now carry `operation` + `category` in both the structured log and the response `error.details` (`backend/controllers/export_controller.go`, pinned by the export `_DBError_IdentifiesOperationAndCategory` tests). No control flipped, no count changes. |
| 1.5 | 2026-09-01 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | The v0.6.5 milestone-gate closure (issue #535). Not a full re-pass — records the milestone's surfaces for §9's "new class" question: the Monica import assistant (#549) adds a **new outbound HTTP client** (`backend/monica/client.go`), covered by the §9 SSRF-rule row (#609); it is not a regression — `monica.NewClient` routes through the shared `httputil.SafeDialContext` guard when `blockPrivate` is set, and the API token is held in the in-memory session only (`asvs-l2.md` 2.10.4 / 5.2.6, `data-retention-lifecycle.md` §12a). The Meerkat import assistant (#550) is an uploaded-file path reusing the shared import framework, not a new client (`data-retention-lifecycle.md` §12b). The new `import_source_links` idempotency ledger (migration `000045`, #724) is covered by the §9 entity/table row (#611): it is declared `fk-cascade-user` in `backend/controllers/delete_cascade_coverage_test.go` and behaviorally swept by that test's `DeleteUser` assertion, backed by a real `ON DELETE CASCADE` FK to `users`. No new authentication path. One gate-closing test gap closed on the branch: failure-path assertions for the two import assistants — `TestMonicaImportSession_AvatarFailurePathCounted` and `TestMeerkatImportSession_ConfirmWithMergeAction` (issue #725). No control flipped, no count changes. |
| 1.6 | 2026-09-01 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | The v0.6.7 milestone-gate closure (issue #537). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. **No new client** (web and Android are unchanged; CON-01…04 add only request-header preconditions to the existing authenticated group). **No new outbound integration** — the concurrency work is entirely inbound REST/CardDAV. **No new authentication path** — `If-Match` (ADR 0008) and `Idempotency-Key` (ADR 0010) are preconditions evaluated *after* `AuthMiddleware`, not credentials. **New persistence targets:** `idempotency_keys` (migration `000047`, #459) and the `job_execution.last_outcome` column (migration `000048`, #526), both covered by the §9 entity/table row (#611) — `idempotency_keys` is declared in `backend/controllers/delete_cascade_coverage_test.go`, behaviorally swept by that test's `DeleteUser` assertion (user-scoped hard delete), and recorded in `data-retention-lifecycle.md` §22. One property distinguishes it from the v0.6.2/v0.6.5 tables in that row, which are telemetry: `idempotency_keys.response_body` transiently holds a copy of the created entity (user content), bounded by `IDEMPOTENCY_KEY_RETENTION_HOURS` (default 24, `<= 0` disables) via the job-locked `PurgeExpiredIdempotencyKeys` cron — §22 covers that copy and its window. CON-01/CON-04 refreshed drifted citation ranges in `asvs-l2.md`/`threat-model.md` in-commit and added `idempotency.go` to `crypto-surface.ignore` (`sha256` = request fingerprint, not a primitive choice). No control flipped, no count changes. |
| 1.7 | 2026-09-02 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | INT-02 (issue #465), not a full re-pass. INT-01's manual integration classification (#464) found one outbound client the semgrep gate (#609) structurally cannot see — `go-oidc` builds its HTTP client inside the library, so there is no `http.Transport` literal to match. INT-02 closes it: `newOIDCHTTPClient` (`backend/services/oidc_service.go`) wires `httputil.SafeDialContext` when the new `OIDC_BLOCK_PRIVATE_URLS` flag is set (default off — LAN identity providers are common self-hosted), threaded through discovery/token/JWKS/UserInfo via `oidc.ClientContext`; pinned by `services/oidc_service_test.go`. Row **5.2.6** gains the OIDC citation; the §9 "outbound HTTP client" row is rewritten to name the library-internal gap and how the INT-01 matrix's per-row `SSRF` field forces the decision for the next one. Also bounded the previously-unbounded outbound email paths (`smtpDialTimeout`/`smtpDeadline`, `resendRequestTimeout`) — a resource-exhaustion hardening, not a control change; row **9.2.1**'s `mailer.go` citation moves with the STARTTLS refactor. No control flipped, no count changes. |
| 1.8 | 2026-09-03 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | INT-03/04 (issues #466/#467), not a full re-pass. **No new class of security-relevant surface.** No new outbound client (`integrations.RetryPolicy` / `DispositionForHTTPStatus` are pure classification helpers; the webhook and sync clients are unchanged). No new outbound integration. No new authentication path (`terminal_reason` / `failed_permanently` are diagnostic state, not credentials; the `Idempotency-Key` *header this now sends on outbound webhook deliveries* is echoed from the existing event-envelope UUID, sent so a receiver can de-duplicate — it is our data leaving, not a new secret). **New persistence:** columns only — `webhook_deliveries.failed_permanently`/`terminal_reason` and `{contact,calendar}_subscriptions.terminal_failure_at`/`terminal_reason` (migration `000049`), all derived diagnostic state on rows already inventoried in `data-retention-lifecycle.md` (webhook deliveries §16, subscriptions §3/§4); no new copy of user content, so no new retention window. `terminal_reason` is a fixed slug set; `last_error` strings were already sanitized/length-capped by `RecordSystemEvent` / `clampRunes`. The new CardDAV `ContactSyncSettings` panel is a frontend view over the existing authenticated `/api/v1/contact-subscriptions` routes (§9 route row's authorization matrix). See ADR 0013. No control flipped, no count changes. |
| 1.9 | 2026-09-03 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | The v0.6.9 milestone-gate closure (issue #539). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. **No new class of security-relevant surface.** **No new client** — the web `ContactSyncSettings` panel is a view over the existing authenticated `/api/v1/contact-subscriptions` routes; Android is unchanged. **No new outbound integration** — INT-01…04 (#464–#467) classify and harden the *existing* 15 external systems (`docs/int-01-integration-classification-matrix.md`, generated from `backend/integrations`); INT-02's OIDC guarded dialer + `OIDC_BLOCK_PRIVATE_URLS` closed a pre-existing library-internal gap and is already recorded (row 1.7). **No new authentication path** — `terminal_reason` / `failed_permanently` are diagnostic state; the outbound `Idempotency-Key` header is our own event-envelope UUID leaving, not a secret (row 1.8). **New persistence: columns + one index only.** Migration `000049` (INT-04) adds derived diagnostic columns to `webhook_deliveries` / `{contact,calendar}_subscriptions`, on rows already inventoried in `data-retention-lifecycle.md` §3/§4/§16 (row 1.8). Migration `000050` (the CAP-01 / DB-01 fix, #498) adds a partial unique index on `contacts(user_id, vcard_uid)` — no new table, no new data. `backend/internal/perfbench` (#469–#471), `backend/internal/largedata` (#468) and `backend/internal/diskspace` (#498) are test/dev infrastructure with no runtime network or privilege surface; `frontend/scripts/check-bundle-budget.mjs` (#556) is a build-time check. **No new privileged CI credential** — `.github/workflows/chaos-tests.yml` (#498) is `permissions: contents: read` throughout, no `secrets.*`, no `id-token`. The §9 mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) all pass on the merge commit. No control flipped, no count changes. |
| 1.10 | 2026-09-07 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | The v0.6.10 milestone-gate closure (issue #540). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. **New authentication path:** the device-grant session exchange (`POST /auth/device/session`, `device_grants`, migration `000051`) from issue #722's biometric session-resume — a device holding an unrevoked grant exchanges it for a fresh session JWT, rate-limited and floor-enforced exactly like `/login` (`routes.go:60-67`). It is a new *instance* of an existing covered class, not a new class: the §9 route row's authorization matrix enumerates it from the live router (public persona) and the entity/table row (#611) sweeps `device_grants` (declared `goCascadeUser` in `backend/controllers/delete_cascade_coverage_test.go:98`, behaviorally asserted by that test's `DeleteUser` sweep; lifecycle in `data-retention-lifecycle.md` §8, `asvs-l2.md` rows updated in-commit by #722). **New auth hardening, not a new path:** `EnforceMinClientVersion` (`backend/middleware/client_version.go`) now guards all four session-minting routes ahead of any credential work, and `/health` advertises `min_client_version`/`api_contract_version` (`health_controller.go:53,61`) — the floor mechanism behind #528/#692, pinned by `client_version_route_test.go`. **No new client, no new outbound integration, no new privileged CI credential.** The one genuinely new *sub-class* the milestone's auth path exposed — a future session-minting route that could ship without rate limiting + the client-version floor — is recorded as the new §9 auth-path row, naming issue **#840** (the mechanical check it builds is what closes that row). No control flipped, no count changes. |
| 1.11 | 2026-09-08 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | Issue #566, not a full re-pass — closes the one gap the #377 threat-model draft recorded against the authorization matrix. The #371/#551 matrix (`authorization_matrix_test.go`) probes six personas that all authenticate the same way (cookie JWT). The new sibling `backend/routes/authorization_matrix_credentials_test.go` extends the same live-router enumeration to the two credential types that are not a cookie JWT: a **CardDAV/CalDAV Basic-auth credential** (asserted 401 on every `/api/v1/*` route — the REST layer only reads a cookie or `Bearer` token — and, on the `/carddav`+`/caldav` surface, reaches only its own collections, never another user's), and a **`carddav`-scoped API token** (asserted 403 on every REST route, `middleware/auth.go`), with a third column asserting a **`full`-scoped API token** matches the cookie-JWT `owner` verdict on every route (scope enforcement neither under- nor over-grants). Reuses `buildTable` + the completeness guard, so a new REST route with no row fails here too; a second guard covers the registered DAV routes. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, persistence, or CI credential; this is test-only. Rows updated in-commit: `asvs-l2.md` **API5** gains the citation; `threat-model.md`'s "Malicious/misconfigured CardDAV client" and "Malicious API client" actor rows move off **gap**. No control flipped, no count changes. |
| 1.12 | 2026-09-09 | (see PR) | ASVS L2 with **29** documented exceptions; MASVS-L1 with 1 | Issue #866 (pen-test #860 finding F-6), not a full re-pass. **Two controls flipped `partial → satisfied`:** server-side session records (`sessions` table, migration `000053`; `sid` JWT claim; `middleware/auth.go:156-186`) now back per-device logout revocation and an idle timeout. **3.3.2** — `SESSION_IDLE_TIMEOUT_HOURS` (default 12 h, `0` disables) rejects an idle session before its 96 h absolute expiry, removing the "no idle timeout" deliberate-position exception. **3.3.4** — `GET/DELETE /api/v1/sessions` + `SessionsSettings.tsx` give a real per-device inventory with immediate revocation, removing the "no session-inventory UI" exception. **3.3.1** re-cited (logout now revokes server-side, not just clears the cookie); **3.3.3** re-cited (`RevokeAllSessions` runs beside every `TokenVersion++`); **1.11.2** reworded (session state is one SQLite table on the shared pool, not an out-of-sync cache); **6.3.1** gains the `session_service.go` CSPRNG citation. **New persistence:** `sessions` — operational bookkeeping, hard-delete, user-scoped (declared `goCascadeUser` in `delete_cascade_coverage_test.go`, swept by `DeleteUser`), TTL-purged by the job-locked `PurgeExpiredSessions` cron; `user_agent`/`ip` captured display-only, no new copy of user content. Lifecycle in `data-retention-lifecycle.md` §23. **New auth mechanism, not a new path** — the `sid` check runs *after* `AuthMiddleware`'s existing credential + `token_version` checks. Exception count 31 → 29. See ADR 0017. |
| 1.13 | 2026-09-09 | (see PR) | ASVS L2 with 31 documented exceptions; MASVS-L1 with 1 | Issue #861, not a full re-pass — a **claim correction** of the same class as Pass-1's F-6 (a row claiming more than the code does). The pen-test engagement (#860, Tester A finding F-1) reported the flat CSV export as leaking `private`/`secret` and `status: suggested` relationship edges. The **behaviour is deliberate and unchanged**: `controllers.ExportData` is the user's own full personal-data backup — it takes no `sections`/`include_sensitive` params (stated in `openapi.yaml`), carries every sensitivity and status labelled by its own column, and is the only full-fidelity export offered, so filtering it would be silent data loss in the one file a user relies on to hold everything. What was wrong was the **documentation**: `asvs-l2.md` rows **1.5.1**, **1.8.2** and **8.3.4**, `data-retention-lifecycle.md` §11, `pii-inventory.md`, `docs/privacy.md`, `README.md`, and CLAUDE.md all asserted the blanket rule "above `normal` is excluded from exports", which was never true of the CSV — a false claim that a black-box tester correctly read as a leak. All corrected in-commit to state the real rule (sensitivity governs copies that leave the instance or reach another party — external sync, contact shares, the neutral-`Card` exports — and is not an access-control tier against the owning user) plus the CSV exception and its rationale. The asymmetry is now **pinned**, not just asserted: `controllers/export_csv_full_fidelity_test.go` (`TestExportCSV_IsFullFidelityBackup_UnlikeVCard`) asserts over one seeded dataset that the CSV withholds nothing and that vCard default-denies exactly the private/secret rows, with a normal-sensitivity control paired to every absence assertion; hand-verified to fail on all four halves (CSV edges, CSV field values, CSV preferences, vCard filter). The web UI's export panel now states what the file contains before the user downloads it. **No control flipped, no count changes, no code behaviour changed.** |
| 1.14 | 2026-09-09 | (see PR) | ASVS L2 with 29 documented exceptions; MASVS-L1 with 1 | Pen-test #860 (Tester A) findings **F-2** (issue #862) and **F-7** (issue #867), not a full re-pass — an auth-hardening delta on `POST /api/v1/login` (+ `/login/2fa` + CardDAV Basic auth). **F-2 (login user-enumeration by timing):** the unknown-identifier branch returned without ever running bcrypt (~11 ms vs ~55 ms). Now the shared `services.SpendDummyPasswordHash` (`backend/services/user_service.go:137-151`) spends an equivalent cost-10 comparison on that branch in both `LoginUser` and `carddav.BasicAuthMiddleware` (the latter refactored off its own local dummy hash). The 401 body/status were already constant. **F-7 (account lockout weaponised for DoS):** the per-account lockout keyed only on the identifier, with no source component and no admin-unlock control, so anyone who knew a victim's identifier could keep any account — including the sole admin — continuously locked out. The lockout now keys on `(identifier, source-IP)` (`middleware/login_lockout.go`, `LoginKey`), so a failed run only denies the source that caused it; a per-identifier backstop (`GlobalAccountLoginAttempts` = 30 across all IPs → a **fixed** `GlobalAccountLockoutDuration` = 15 min, bypassed for IPs that authenticated within `KnownGoodIPTTL`) still bounds an IP-rotating attacker without denying the real user. Rows updated in-commit: `asvs-l2.md` **2.2.1**, **API2**, **8.1.4**, **API4**, **1.11.2** and the rate-limiting summary; drifted `user_controller.go` citations in rows 2.1.10 / 2.2.3 / 2.5.3 / 5.1.5 re-pointed and the #722 drift-ignore entry followed. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, persistence, or CI credential; the lockout state stays the same in-memory, per-key, restart-visible map. Tests: `middleware/login_lockout_test.go`, `controllers.TestLoginUser_UnknownIdentifier_ResponseIsIndistinguishable` / `TestLoginUser_Griefing_DoesNotLockLegitimateIP` / `TestLoginUser_SameIP_StillLocksAfterMaxAttempts`, `carddav.TestBasicAuthMiddleware_Griefing_DoesNotLockLegitimateIP`, `services.TestSpendDummyPasswordHash` — each hand-verified to fail with the fix reverted. No control flipped, no count change (F-2/F-7 were open findings, not exception-register entries). |
| 1.15 | 2026-09-09 | (see PR) | ASVS L2 with **28** documented exceptions; MASVS-L1 with 1 | Issue #873 (credentialed pen test, Tester B), not a full re-pass. **One control flipped `partial → satisfied`: 2.8.4** (TOTP single-use within validity). A TOTP code that passed `valid2FAProof` was not burned, so it could be replayed to authenticate a second independent session inside its ±1 step (~90 s) window — recovery codes were already single-use, TOTP was not. Fix: `users.totp_last_used_step` (migration `000054`) records the RFC 6238 counter step of the last accepted code; `services.ValidateTOTPStep` reports a code's step and `services.BurnTOTPStep` does a single conditional `UPDATE … WHERE totp_last_used_step IS NULL OR totp_last_used_step < ?`, so a replay (or an older step) loses the compare and is rejected — the same atomic-`WHERE` single-use shape `ConsumeRecoveryCode` uses (V2.6.1, V11.1.6). `valid2FAProof` (login, 2FA disable, recovery-code regen) and `ConfirmTwoFactor` (enrollment) both burn the step; disable / admin 2FA reset clear it to NULL. **2.8.5** stays `partial` but is reworded: a replayed code is now rejected and recorded as a failed 2FA step (`AuditOpLoginFailed`) that counts toward the account lockout, so reuse is detected and logged — the residual gap is only the absence of a proactive owner notification (the 2.2.3 / 2.5.5 gap). **No new class of security-relevant surface** — no new route, client, outbound integration, or authentication path; `totp_last_used_step` is a derived monotonic marker on the already-inventoried `users` row (`data-retention-lifecycle.md` §4), not a new copy of user content. Tests: `backend/services/twofactor_replay_test.go`, `backend/controllers/two_factor_controller_test.go` (`TestTwoFactor_TOTPReplayRejectedWithinWindow`, `TestTwoFactor_ConfirmRecordsStepSoEnrollmentCodeCannotLogin`), each hand-verified to fail without the burn. Exception count 29 → 28. |
| 1.16 | 2026-09-09 | (see PR) | ASVS L2 with **26** documented exceptions; MASVS-L1 with 1 | Pen-test #860 (Tester B) findings **#869** and **#872**, not a full re-pass. **Two controls flipped `partial → satisfied`:** `SecurityHeadersMiddleware` (`backend/middleware/security_headers.go:49-57`) now sets `Cache-Control: no-store` on every `/api/` response. **8.1.1** and **8.2.1** — both were in §7's "known small gaps with an obvious fix" group with the identical gap text ("no `no-store` on API responses"); the header closes the shared/intermediary-cache and browser-bfcache retention of authenticated JSON. Scoped to the `/api/` prefix, which is the whole boundary — this Go process serves no static assets (the SPA's hashed, ETag'd files are nginx's and keep their long-cache). Pinned by `backend/middleware/security_headers_test.go` (`TestSecurityHeadersMiddleware_CacheControlNoStoreOnAPI`, `TestSecurityHeadersMiddleware_NoCacheControlOffAPI`), hand-verified to fail with the header removed. Issue **#869** is the paired change and does **not** move a row: `deliverWebhook` (`backend/services/webhook_service.go`) stopped storing the raw Go transport/URL-parse error on the webhook delivery record (an internal port-scan oracle echoed via `GET /api/v1/webhooks` and `POST /api/v1/webhooks/:id/test`), collapsing it to two generic constants with the detail logged server-side — row **7.4.1** gains a sentence alongside the existing notification-endpoint exception; the receiver's own `"unexpected status N"` is unchanged. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, persistence, or CI credential; both changes are within existing middleware/service code. Drifted `security_headers.go` citations (the `strings` import shifted every line) re-pointed in-commit across `asvs-l2.md`, `citation-drift.ignore`, and `deployment-baseline.md`. Exception count 28 → 26 (this pass follows #873's pass 1.15). |
| 1.17 | 2026-09-10 | (see PR) | ASVS L2 with 26 documented exceptions; MASVS-L1 with 1 | The v0.6.12 milestone-gate closure (issue #542). Not a full re-pass — records the milestone's surfaces for §9's "new class" question, and records the external-assessment decision. **External assessment (#511):** closed against a standing position, now written down as `asvs-l2.md` **P8** and reflected in the header "Performed by" row and §6's "what this pass could not verify" — no commissioned commercial pen test (hobby project, no budget), not deferred to a gate; the #860 two-agent credentialed engagement (Opus 4.8 + DeepSeek V4 Pro, live Caddy-fronted server, 15-area methodology) is the external assessment, every finding filed (#861–#874, #876, #877) and dispositioned, limitations and not-tested scope enumerated in #860's coverage record. **No new class of security-relevant surface.** The milestone's security fixes landed as their own passes above: #866 server-side session store (`sessions` table, `sid` claim, ADR 0017) in pass 1.12 — the one new persistence target *and* new auth mechanism, both already recorded there; #873 `users.totp_last_used_step` (migration `000054`) in pass 1.15 — a derived column on the already-inventoried `users` row; #862/#867 lockout re-keying in pass 1.14 — in-memory, no persistence; #869/#872 `Cache-Control: no-store` + webhook error scrub in pass 1.16 — existing middleware/service code; #566 credential-persona authorization matrix in pass 1.11 — test-only. **No new client, no new outbound integration.** The pen-test environment (`backend/cmd/pentestseed`, `docker-compose.pentest.yml` + hardened overlay, `docs/development/pentest-environment.md`, #849/#857) is dev/test infrastructure with no runtime network or privilege surface — same disposition class as `internal/perfbench` / `chaos-tests.yml` in pass 1.9. **No new privileged CI credential** — `release.yml`'s GitHub App token is unchanged from v0.6.10 (§9 CI-credential row). The §9 mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) pass on the merge commit. No control flipped, no count change. |
| 1.18 | 2026-09-10 | (see PR) | ASVS L2 with 26 documented exceptions; MASVS-L1 with 1 | Issues #508 (repository governance) + #513 (CI/CD supply-chain hardening), not a full re-pass. **The CI/CD pipeline is now an explicitly-modeled trust boundary** — a new `## The CI/CD pipeline as a trust boundary` section in `threat-model.md` (attack surface, trust-assumptions table, threat→control for workflow injection / secret blast radius / forged publish trust / dependency hijack / compromised build), and a matching "Compromised CI/CD pipeline" row in the Actors × trust boundaries table. This is the closing of §9's "no adversarial treatment of the release path" gap (#377), **not a new unrecorded surface**. Governance settings are now committed as `.github/rulesets/{main-protection,main-hard-checks,tags-v}.json` with `docs/development/repo-governance.md` as the prose; `backend/cmd/governancecheck` (a `Docs & security-doc citations` step) asserts `main-protection.json`'s required checks are exactly the per-PR mandatory gates in `.github/release-gates.json`, and `.github/workflows/governance-drift.yml` diffs the committed state against the live rulesets weekly. **#513 hardening:** the operator `cosign verify` commands in `release-verification.md` now pin `--certificate-identity-regexp` to `docker-publish.yml@refs/tags/v*` (images/APK) / `syft-sbom.yml@refs/heads/main` (main SBOM), so a signature from any other workflow does not verify; `governancecheck` fails the build if the pin is loosened; `governance-drift.yml` re-checks it against the latest real release. Rows updated in-commit: `asvs-l2.md` **1.1.4**, **1.14.2**, **10.3.1**, **14.2.4**; §9's "privileged CI credential" row reworded (the bypass lists are now reviewable). No control flipped, no count change. |
| 1.19 | 2026-09-10 | (see PR) | ASVS L2 with 26 documented exceptions; MASVS-L1 with 1 | Issue #608, a **verification-process change**, not a re-pass over code. The `citecheck` citation gate and the ASVS/MASVS re-verification obligation both moved into the REL-06 release workflow (`.github/workflows/release.yml`, issue #499): `citecheck` is now `release_gate: true` in `.github/release-gates.json` (polled on the release commit) **and** a direct hard step in `release.yml`, and `release.yml` refuses to push the release tag if this report's §10 changelog carries no new row since the previous release tag (recorded escape: dispatch input `ack_asvs_current=<reason>`, captured in `release-metadata.json`). The 16 hand-maintained gate-issue checkboxes and the `.github/ISSUE_TEMPLATE/milestone_gate.md` standing criterion that duplicated the citation obligation are **retired** — the template now points at §8 (#608's "end with fewer homes"). §8 gains "The re-verification obligation, and where it now lives"; §9's "Every milestone" / "Every release" tiers and the privileged-CI-credential row are rewritten to describe what actually runs (that row also notes `release.yml` now holds `actions: write` to dispatch the two release-tier suites lacking a `push:main` trigger, per #499). Related, same PR: #355 attaches a real SLSA provenance `mycorrhizal-apk.intoto.jsonl` + a `SHA256SUMS` manifest to each Release (`docker-publish.yml` `apk-provenance` job via the `slsa-github-generator` reusable workflow). **No new class of security-relevant surface** — no new route, client, outbound integration, persistence, or authentication path; the `actions: write` scope is a new privilege on an existing `workflow_dispatch`-only workflow, recorded in the §9 row. No control flipped, no count change. |
| 1.20 | 2026-09-10 | (see PR) | ASVS L2 with 26 documented exceptions; MASVS-L1 with 1 | Issue #446 (REL-02, release-candidate workflow), a **release-process change**, not a re-pass over code. `release.yml` gains an RC path: an `-rc.N` version is cut from `release/vX.Y.0` (new `ref` input), runs the same mandatory gate battery, but defers the schema-fixture registration and the ASVS §10-row gate to promotion. New `promote-rc.yml` promotes an RC to its final tag by **copying** — container images re-tagged by digest, release assets copied byte-for-byte, the final `v*` tag pushed with `GITHUB_TOKEN` so `docker-publish.yml` does not rebuild — with `promotion-metadata.json` asserting `digest_rc == digest_final`. `docker-publish.yml` marks an `-rc.` Release a pre-release / never `make_latest`, keeping RCs off the in-app update check (`/releases/latest` excludes pre-releases) and Obtainium. New governance-as-code: `.github/rulesets/release-branches.json` (the `release/*` ruleset) carries the **same required checks as `main`** — `backend/internal/governance.CheckReleaseBranchesMatchMain` fails the build on drift, so "RC gates match release gates" is enforced. New `.github/workflows/rc-fix.yml` gates PRs into `release/**` on an `rc-finding`-labelled issue link or an `rc-chore:` opt-out. **No new class of security-relevant surface** — no new route, client, outbound integration, persistence, or authentication path. **Privileged CI credential:** `promote-rc.yml` mints the existing release App token (release-branch commit + main merge only; the final tag is `GITHUB_TOKEN`) — a new *user* of an existing credential on an existing `workflow_dispatch`-only surface, recorded in the §9 row. One new pinning entry: promoted images also carry a `promote-rc.yml@refs/tags/v` cosign signature, so `release-verification.md`'s identity regexp is now `(docker-publish\ | promote-rc)` — still workflow-pinned, `governancecheck` #4 satisfied. Citations in `asvs-l2.md` rows 1.14.2 / 1.14.3 / 14.2.1 / 14.2.4 / 14.2.5 (+ `masvs-l1.md` CODE-2, `citation-drift.ignore`) renumbered for the `docker-publish.yml` line shift, in-commit. No control flipped, no count change. |
| 1.21 | 2026-09-14 | (see PR) | ASVS L2 with **23** documented exceptions; MASVS-L1 with 1 | Issue #940 (adversarial-review finding: distributed credential stuffing is undetected), not a full re-pass — a single-control delta. **One control flipped `partial → satisfied`: 11.1.8** (configurable alerting). The per-`(identifier, source-IP)` lockout and the per-identifier backstop each stop a single-source attack, but a spray of one common password across thousands of accounts from a botnet keeps every individual budget under threshold, so no key ever crosses its limit and nothing alerts. **New `backend/middleware/auth_velocity.go`** adds an in-memory, instance-wide sliding-window velocity signal that counts failures per window across **all** identifiers; the *distinct-identifier* count is the discriminator (one account failing from many IPs is a targeted attack and does not trip it). A trip engages a short, self-clearing login throttle that refuses sources which have not recently authenticated — the botnet is stopped while a returning legitimate user (their `(identifier, IP)` pair, or any source that recently authenticated within `KnownGoodIPTTL`) is exempt, preserving the #867 no-griefing posture — and a coarse-mutex latch held ≥ 2 × `ALERT_EVAL_INTERVAL_MINUTES` so the polled evaluator cannot miss a spike that starts and ends between two runs. **`backend/services/alerting_conditions.go`** adds the `auth_spray` condition, dispatched through the existing `alert.raised`/`alert.cleared` webhooks and admin personal channels; window, thresholds and throttle are env-configurable (`AUTH_SPRAY_*`) and the condition is switchable (`ALERT_AUTH_SPRAY_ENABLED`). **11.1.7** stays `partial`, reworded (the new signal is a *security* anomaly, not business-activity monitoring); **8.1.4** and the rate-limiting summary gain the citation. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, persistence, or CI credential; per-process in-memory state like the rest of the rate limiter, no copy of user content, and identifiers are deliberately kept out of the alert payload (counts only). Tests: `backend/middleware/auth_velocity_test.go` (many-identifier/many-IP spray trips and throttles; single-account brute force does not; known-good bypass; window / throttle / incident expiry; disabled), `backend/services/auth_spray_alert_test.go` (condition verdicts + the raise/recover transition), and `backend/controllers/user_controller_test.go` (`TestLoginUser_DistributedSprayTripsInstanceThrottle`), each hand-verified to fail with the signal disabled. Partial-row count 24 → 23. |
| 1.22 | 2026-09-14 | `e96de2ed` | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.1 milestone-gate closure (issue #980), the "must-not-lose-data tier" (concurrency CAS, purge/retention correctness, DB recovery, backup trust, alert/health truthfulness, rate-limit/credential-stuffing abuse). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. #940 already got its own pass above (1.20, same milestone — one control flip, no new surface); this row covers the remaining seventeen: **#919/#922** (CardDAV reconcile + contact-merge concurrency windows), **#920/#924** (CAS-based conditional-write races), **#928** (`relationship_edges` natural-key unique index, migration `000055`) — all inbound REST/CardDAV concurrency fixes on existing routes, no new route or client. **#921/#926** (startup corruption probe, fail-closed on a populated versionless DB) add no new table or route — the probe is a read-only `PRAGMA integrity_check` pass at startup and in `doctor -repair`, no new persistence. **#943** (backup snapshots HMAC-signed) is a new crypto call site, already recorded in-commit under the P5 documented position (`asvs-l2.md` "Authenticity is signed" §, `atrest.BackupSigningKey`) and covered by `citecheck`'s crypto-surface gate — not a new *class*, an instance of the already-enforced "crypto call site" row. **#954** (rate limiting keyed on network prefix) and **#971** (`DELETED_RETENTION_DAYS<=0` guard) touch only existing middleware/service logic. **#973** (`alert_states.pending_notify`, migration `000056`) is a column on the already-inventoried `alert_states` table (§9 entity/table row, #611), not a new persistence target. **#975** (purge failures surfaced to `job_stopped`) and **#976** (`/health/ready` DB-volume probe) change no external surface. **#978** (purge coverage extended to Paperless/Seafile/WebDAV/LinkFieldType/CalendarSubscription/ContactSubscription, reach-out suggestions purged on `AUDIT_RETENTION_DAYS`, request/access logs documented as operator-owned in `data-retention-lifecycle.md` §24) purges existing tables and documents an existing, previously-unrecorded log stream — no new store. **#963/#1029** (Android call/SMS interaction-capture matching + known-contacts-only filter) and **#995** (idempotency response-store terminal-state fix, existing `idempotency_keys` table) are client/backend bug fixes with no new client platform, route, or table. **No new class of security-relevant surface across the whole milestone** — no new route, client, outbound integration, authentication path, persistence target, or CI credential. §9's mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) and the `Docs & security-doc citations` job pass on the merge commit. No control flipped in this row (11.1.8 already flipped in 1.20), no count change. Milestone issue list note: #980's own "every issue closed" checklist named 15 issues (#919–#978) but the milestone gained three more before closure — #963, #995, #1029 — all closed and included in this pass; #980's text should be corrected to match. |
| 1.23 | 2026-09-14 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | Issue #965 (cross-domain adversarial review, Android), not a full re-pass — a **PLATFORM-3 claim correction plus its code fix**, the same class as Pass-1's F-6/#861 (a row claiming more than the code did). The custom-scheme OIDC return was recorded `satisfied` on "the scheme is unique to the app", which is false: Android custom schemes are first-come-first-served and any app can register `mycorrhizal://`. The callback delivered the raw session JWT in a query parameter, so an app that won the intent race received a usable credential. **New authentication path — the inverse:** `POST /api/v1/auth/oidc/native/exchange` (`routes.go` OIDC block) redeems a short-lived, single-purpose exchange code for a session JWT. It is a new *route and path*, but strictly narrower than the one it replaces: the code is a HS256 JWT carrying `purpose: oidc_native_exchange` that **AuthMiddleware now refuses for every non-empty `purpose`** (`middleware/auth.go:109-120`, generalized from the `2fa`-only check so no future single-use token can double as a bearer), it expires in 120 s, and it is redeemable only with the PKCE S256 verifier the app never transmits (RFC 7636, `services/oidc_native.go`; `oauth2.S256ChallengeFromVerifier` pinned by the RFC Appendix B vector). An interceptor of the interceptable scheme gets an unusable code. The login start now also requires the app's `state` nonce + S256 challenge and fails closed otherwise (`controllers/oidc_controller.go`); the callback emits `code`+`state`, never `token`. **New persistence:** none — the code is a stateless signed token; the app stores only its own pending `state`+verifier (Keystore-encrypted, `OidcPendingRequestStore.kt`). **No new client, no new outbound integration, no new CI credential.** MASVS-L1 PLATFORM-3 stays `satisfied` but on a now-true basis (its rationale and citations are rewritten in-commit); PLATFORM-2 is re-cited. Tests: `services/oidc_native_test.go`, `controllers/oidc_native_exchange_test.go`, the updated `OidcReturnParsingTest` (including the legacy raw-`token` regression), `OidcPkceTest`, and `middleware` `TestAuthMiddleware_RejectsPurposeScopedTokens`. No control flipped, no count change. |
| 1.24 | 2026-09-15 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.2 milestone-gate closure (issue #982), "client trust & compatibility" (Android logout/session, offline tombstones, TLS trust, OIDC return, phone matching; web correctness; the client/server version matrix). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. Two of the milestone's thirteen issues already got their own pass above and are not revisited here: **#963/#1029** (Android call/SMS PhoneKey matching) in pass 1.22, **#965** (Android OIDC PKCE binding) in pass 1.23. The remaining eleven: **#957/#967** (PR #1045, Android logout + session-refresh storm) make `CurrentSessionRevoker` and the FCM-deregistration call new *authenticated callers* of the already-existing `DELETE /api/v1/sessions/:id` and `DELETE /api/v1/notifications/devices/:id` routes (`routes/routes.go:456-457,499`) — no new route, no new persistence; the 401-triggered single-flight refresh guard is Android-client-only state with no server surface. **#959** (PR #1044, Android offline-mirror tombstones) is a bug fix (the sync engine now consumes the real `?since=` change feed instead of misreading a static collection-name list as tombstone ids) plus a **claim correction** of the same shape as pass 1.23's own PLATFORM-3 fix and Pass-1's F-6/#861: `data-retention-lifecycle.md` §8, `pii-inventory.md`, and `asvs-l2.md` 8.3.2 previously described a tombstone-propagation mechanism that did not exist and now state the real one; no new route or persistence, the sync endpoint is pre-existing. **#961** (PR #1046, Android TLS trust docs) is a pure claim correction with no code change: `network_security_config.xml`'s header comment and `masvs-l1.md` NETWORK-3 claimed a KeyChain-import self-signed-cert flow that was never implemented; both now state the true constraint (system-CA-only trust, no import path — a self-hosted deployment needs a cert chaining to an OS-trusted CA), pinned against regression by a new `NetworkSecurityConfigTest` case. **#914/#927** (PRs #1054/#1051, minimum-supported-server + Android-floor release gates) add test-only CI surface — a third pinned `v0.6.0` container in `docker-compose.compat-test.yml`, new instrumented tests exercising it, and a corrected/expanded `Android E2E (emulator, minSdk 26)` release-tier gate now also firing on `push:main` — same disposition as pass 1.9's `chaos-tests.yml` and pass 1.17's pentest environment: dev/test infrastructure with no runtime network or privilege surface. **#917** (PR #1064, reference-client interop matrix) found a real bug via a live DAVx5 pass — `.well-known/{caldav,carddav}` only accepted GET, and DAVx5's autodiscovery issues PROPFIND — fixed by registering `PROPFIND` on the same path through the same handler function as the existing GET (`caldav.WellKnownRedirect`/`carddav.WellKnownRedirect`, `routes/routes.go:700-701,724-725`): identical authorization posture (public, unauthenticated, redirect-only), not a new authenticated route. Its new nightly/path-gated `davx5` CI job (`reference-clients-e2e.yml`) driving a real DAVx5 APK against a real emulator and a real backend is test infrastructure, same disposition as above. **#958/#960/#962** (PRs #1049/#1048/#1050: auxiliary-fetch isolation, AppBar search stale-response guard, local-time date rendering) are frontend-only bug fixes with no server surface, no new route, and no new persistence. **#964** (PR #1042, WCAG-AAA test coverage) adds deterministic and Playwright accessibility tests plus a scoping correction in `assets/colors/README.md` (the AAA claim now names the four surface pairs it actually covers); not a security control, recorded here only because it is a documented-claim correction of the same shape as the ones above. **No new class of security-relevant surface across the whole milestone** — no new route, client platform, outbound integration, authentication path, persistence target, or CI credential. §9's mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) and the `Docs & security-doc citations` job pass on the merge commit (https://github.com/DrewBrunning/mycorrhizal-crm/actions/runs/35005145624/job/104502927276, completed 2026-09-15T18:04:47Z). No control flipped in this row (11.1.8 flipped in 1.20, PLATFORM-3 flipped in 1.22), no count change. |
| 1.25 | 2026-09-16 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.3 milestone-gate closure (issue #984), "domain fidelity & test strength" (neutral-model round-trip correctness, loss-report honesty, test-suite strengthening). Not a full re-pass — records the milestone's surfaces for §9's "new class" question; none of this milestone's ten issues is itself a security control, it strengthens the tests and gates other passes' claims rely on. **#966** (day-only `---DD` dates no longer misimport as a month) and **#969** (vCard DATE-AND-OR-TIME timestamps normalized to RFC 3339/UTC) are round-trip fidelity fixes: `TestRoundTrip_AnniversaryBirthDayOnly`, `TestImport_AnniversaryBirthTimestampWithOffset` (`backend/vcard4/date_partial_test.go`, mirrored in `backend/vcard3`). **#968** makes the loss report honest for the sibling fields (Email/Phone `.Label`, `OtherOnlineServices`) that the correspondence table previously left unclassified and so silently un-reported: `TestMatrixCoversEveryCorrespondenceRow`, `TestLossReportCorrespondence` (`backend/correspondence/matrix_test.go`). Making that drop reportable surfaced a real, previously-undetected regression in the real-server CardDAV round-trip suite (`TestCardDAVReferenceServer_RoundTrip`, `backend/services/carddav_radicale_integration_test.go`) — the Radicale/Baikal/Nextcloud divergence registers had gone stale because #968's own PR (#1074) touched no file the suite's CI path filter watches. Found during this gate's own review and filed as **#1077**, fixed same-milestone by PR #1078 (the registers now carry `email.label`/`phone.label`); the suite is green again as of this pass. **#910/#911/#912** strengthen the adversarial-corpus and restore-completeness self-tests and mechanically gate ADR-0012 invariant↔probe↔DB-03-matrix drift — this discharges issue **#502**'s verify criterion that the architecture review's weak-gate findings result in strengthened gates, not documentation. **#915** adds a mutation-testing threshold ratchet reaching the Go safety-critical paths (migration/backup/delete-cascade/import/data-integrity) via a new nightly `go-mutation.yml` workflow (gremlins) — `permissions: contents: read` throughout, no `secrets.*`, no `id-token`, same disposition as pass 1.9's `chaos-tests.yml`. **#974/#977/#979** are test-infrastructure trust fixes (a flake-budget surface with no blanket retry, golden-fixture/root-testdata CI parity, `codecov.yml` config validation) with no runtime surface. **No new class of security-relevant surface, no new persistence** (no migrations landed in this milestone's scope), **no new route, client, outbound integration, authentication path, or CI credential.** §9's mechanical checks and `cmd/citecheck` (re-run locally for this pass: all citations resolve, no new drift) pass; the `Docs & security-doc citations` job is green on PR #1080 (https://github.com/DrewBrunning/mycorrhizal-crm/actions/runs/35047445972/job/104640274651), the most recent commit in this milestone's line at the time of this pass. No control flipped, no count change. Process note: issue #984 was closed 2026-09-16T01:57Z, 18 minutes before PR #1078 (closing #1077, found during #984's own review) merged at 02:15Z — its checkboxes were checked without the citation text the issue's own template requires; this row plus a follow-up comment on #984 supply that evidence retroactively. |
| 1.26 | 2026-09-16 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | PR #1080 (found via Codecov coverage triage on Android, no filed issue), not a full re-pass — a MASVS **STORAGE-1 claim correction**, the same class as pass 1.23's PLATFORM-3 fix and Pass-1's F-6/#861: a documented mechanism was not actually true of the shipped code. `masvs-l1.md` P4 has asserted since its 2026-08-25 resolution (issue #385) that "the plaintext file is overwritten before deletion" during the Room-cache SQLCipher migration; `RoomCacheEncryption.overwriteFile` read `file.length()` *after* `FileOutputStream(file)` had already opened (and truncated) it, so `remaining` was always `0` and the secure-erase write loop never ran a single iteration — the plaintext file went straight from its real content to empty with the underlying disk blocks never actually overwritten, exactly the "stolen device, data recoverable from freed blocks" scenario the control exists to prevent. Fixed by capturing the length before the truncating stream opens (`android/core/data/src/main/kotlin/com/mycorrhizal/crm/data/local/RoomCacheEncryption.kt:239-241`), pinned by three new JVM tests in `RoomCacheEncryptionTest.kt` (`overwriteFile replaces the content but keeps the file length`, `overwriteFile on an empty file does not throw and leaves it empty`, `overwriteFile handles content larger than its internal buffer`), each hand-verified to fail against the pre-fix code per this repo's workflow. **STORAGE-1 stays `satisfied` — on a now-true basis, not a status flip** (the row was never marked `partial` for this gap, since the bug silently defeated a step nobody had reason to doubt); `masvs-l1.md` P4 gains the correction note in-commit. **No new class of security-relevant surface** — the fix is confined to an existing migration helper on an existing client: no new route, persistence, or authentication path. The same PR's other two fixes (`AuthRepositoryImpl` session-rollback on profile-fetch failure; `RoomCacheEncryption.runReencryption`'s `swapped`-ordering crash-recovery misattribution) are correctness bugs with no ASVS/MASVS control implication — the first already surfaced an error to the user rather than hiding a security-relevant state, and the second affected only diagnostic reporting of an already-successful swap. Exception count unchanged. |
| 1.27 | 2026-09-17 | `9235efde` | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.4 milestone-gate closure (issue #985), "release gates & supply chain" (mandatory-gate hard-blocking, governance-drift/RC-fix enforcement, release rehearsal, reproducibility claim accuracy, dependency governance, HTTP-layer hardening) — triggered by `release.yml`'s own re-verification gate refusing to cut a release with no new §10 row since v0.8.3. Not a full re-pass — records the milestone's surfaces for §9's "new class" question, plus two v0.8.5 issues that landed on `main` ahead of this release cut. **Seven of the ten milestone issues are CI/release-process or test-coverage fixes with no application control to cite:** #913 (a mandatory gate that never reports is now `FAIL_MISSING`, a hard block, not a warning), #916 (`governance-drift.yml`'s two jobs now actually exit non-zero on drift instead of `continue-on-error`), #923 (`TestCrossVersionRestoreAtScale` wired into the large-dataset CI job's `-run` alternation — it had never executed anywhere), #925 (the RC-fix criterion is now a real `required_status_checks` entry on `release-branches.json`, not just documented), #929 (the `dry_run` rehearsal path's duplicate-registration bug fixed, exercised weekly by new `release-dry-run.yml`), #996 (new `post-publish-smoke` job pulls the just-published image by digest and boots it — `permissions: contents: read, packages: read`, no new privileged credential), #997 (`TestBadReleaseRollbackDrill` drives real HTTP traffic against a restored pre-migration snapshot; `promote-rc.yml`'s digest-match gate moved earlier, before the tag is pushed). **One already carries its own ASVS citations, added in-commit:** #948 (HTTP-layer hardening) — `backend/controllers/http_desync_test.go` regression-tests request-smuggling/CRLF-filename handling over a real TCP connection, and `docker/nginx.conf` gained `server_tokens off`/`client_header_timeout`/`large_client_header_buffers`; rows **1.12.2**, **1.14.1**, **12.3.4** already cite it. **Two are process/tooling fixes with no row to flip:** #947 (reproducibility claim corrected from "byte-reproducible" to "path-independent"; new weekly cross-runner `go-binary-independent-rebuild` job substantiates the stronger claim) and #942 (Android Gradle dependency locking gives Trivy visibility into that graph; `dependency-exceptions.ignore` now requires an `escalated:` reason past two renewals) — its new `android-license` scan job is `permissions: contents: read`. **Two v0.8.5 issues landed early and are covered here rather than left for that gate:** **#934** — OIDC partial configuration (one or two of `OIDC_PROVIDER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` set) used to boot with SSO silently disabled and only a log line; `config.Validate` (`backend/config/config.go:910-959`) now fails boot naming exactly what's missing, and once complete also rejects a malformed provider URL or an empty `OIDC_SCOPES` entry — this had no citation anywhere in `asvs-l2.md` and gets one for the first time in this pass, added to row **1.2.4** (tests `TestValidate_OIDCPartialConfig`, `TestValidate_OIDCPartialConfig_NamesMissingVars`, `TestLoadConfig_OIDCPartialEnvFailsValidation`, `TestStartup_PartialOIDCConfig_FailsNamingTheVariable`). **#951** — a boot-time advisory (`Config.PublicExposureWarnings`) names every `*_BLOCK_PRIVATE_URLS` guard still off when the deployment looks public-facing (`FRONTEND_URL=https://` or `COOKIE_SECURE=true`); already cited in-commit on row **5.2.6**. **#938** (at-rest master-key resolution consolidated into one `atrest.ResolveMasterKey` reader instead of two independently-maintained ones) is an internal-consistency fix with no citation to move. Re-ran `cd backend && go run ./cmd/citecheck` and `-drift` after all of the above (including the `main.go`/`config.go` line shifts #934/#951 caused, already renumbered in-commit): all citations resolve, no new unaccepted drift. **No new class of security-relevant surface across the whole span since v0.8.3** — no new route, client, outbound integration, authentication path, persistence target, or CI credential. Two commits in the span are CI/test infrastructure with no ASVS-relevant issue: #1103 (Android DAVx5 reference-client login flakiness fix) and #1104 (fixing #947's own new independent-rebuild job from being silently skipped on PRs outside its path filter, the same bug class #913 hardens against at the release-gate layer). §9's mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) pass on `main` HEAD `76ef65ab`. No control flipped, no count change. |
| 1.28 | 2026-09-17 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.5 milestone-gate closure (issue #981), "Configuration" (generated register, per-value validation, clamp-vs-fail-fast, OIDC, out-of-Config variables, necessity review, secure defaults) — the last two milestone issues not already covered by pass 1.27 (#934/#951 landed early and were recorded there), plus everything else that reached `main` since that pass's `76ef65ab` HEAD. **#933** (PR #1107) adds the register itself: a `cfgreg` struct tag on every `config.Config`/`OIDCConfig` field (env var, type, default, required, restart-required, range/enum, description), a reflection-based reader (`backend/config/register.go`), and `cmd/genconfigreference` rendering `docs/configuration-reference.md`; `TestConfigRegisterCoversEveryField` fails CI on any untagged field, `TestConfigurationReferenceDocUpToDate` gates drift on the committed doc. This is the substrate the rest of the milestone's validation work sits on, and the doc it renders is the artifact API8 ("Security Misconfiguration", `config.go` boot-time validation) and 14.2.2 ("Unneeded features removed") already cite generically — both rows' citations still resolve unchanged after the tag additions (confirmed by `citecheck`, run below), so neither needed a text edit. **#935** (same PR) closes seven previously-unchecked config inputs — `COOKIE_DOMAIN`, `API_RATE_LIMIT_BURST`/`_INTERVAL_MS`, five retention-day fields, and `OIDC_PROVIDER_URL` — each hand-verified in the issue to have booted with a garbage value and zero errors before the fix; strengthens the rate-limiting row's existing citation (`config.go:945-978`, no new class of value, just validation the row already implied) and adds a first check on `OIDC_PROVIDER_URL`'s format (`TestValidate_OIDCProviderURLFormat`), read alongside row 1.2.4's #934 fix. **#936** (same PR) brings `DEMO_MODE`/`LOG_LEVEL`/`LOG_PRETTY`/`GIN_MODE` into `Config` (previously raw, scattered `os.Getenv` reads invisible to the register and to `Validate`) and gives `PROFILE_PHOTO_DIR` a single reader (`main.go` now sets `models.DefaultPhotoDir` once from the loaded config instead of a second independent env read) — `TestValidate_GinModeEnum` strengthens 14.2.2's "release-mode config guards" claim (`config.go:810-838`, the `FRONTEND_URL="*"`+`GIN_MODE=release` refusal) by making `GIN_MODE` itself a checked enum rather than an unchecked string compared against `"release"`. **#937** (same PR) moves 15 `ALERT_*`/`STORAGE_*`/interval fields from silent clamp-in-`LoadConfig` (four previously clamped with no `WARN` at all) to fail-fast `Validate()` checks, plus `getIntEnvChecked` so a non-numeric value also refuses to boot by name instead of silently defaulting — a direct instance of the misconfiguration class API8 already covers, now backed by `TestValidate_FailFastIntFields`/`TestValidate_StorageCriticalPercentMustExceedWarnPercent`/`TestLoadConfig_FailFastFieldParseErrorSurfacesInValidate` where before there was nothing. **#938**'s remaining finding (this pass, no separate PR yet — `RESEND_TO_EMAIL` was declared with a `cfgreg` tag stating it was dead but never actually removed, contradicting PR #1105's claim that it already had been) is closed in this same change: the field is deleted from `Config` and the register/doc regenerated (`docs/configuration-reference.md`, now 94 fields); the at-rest master-key single-owner half of #938 was already closed and cited (or explicitly noted as needing no citation) in pass 1.27. None of #933/935/936/937/938 is itself a new control — each strengthens an already-`satisfied` row (API8, 14.2.2, 5.2.6-adjacent rate limiting, 1.2.4) or removes dead surface; no row flips status and the exception count is unchanged. **Two supply-chain CI fixes also landed in this span:** #1109/#1113 (`docker/setup-buildx-action`'s BuildKit builder image now pinned by digest on the two build jobs whose output is compared or published, closing `docs/security/reproducible-builds.md`'s last unpinned toolchain input) already recomputed its own shifted `asvs-l2.md` citations in-commit; #1114 (Scorecard `curl`-into-`python3` false-positive on `min-browser-check.sh` rewritten to the existing capture-then-echo idiom used a few lines above) has no ASVS-relevant control, same disposition as pass 1.27's process/tooling fixes. #1111/#1112 are pure documentation (this gate-template lesson) with no code or control surface. **No new class of security-relevant surface since `76ef65ab`** — no new route, client, outbound integration, authentication path, persistence target, or CI credential. Verified for this pass: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...` (full suite, including `authorization_matrix_test.go` and `delete_cascade_coverage_test.go`) green; `go run ./cmd/citecheck` clean (all citations resolve, no new drift, crypto-surface gate unchanged); `go run ./cmd/docscheck`, `go run ./cmd/governancecheck`, `go run ./cmd/releasegatecheck`, `go run ./cmd/depexceptions`, `go run ./cmd/deprecations` all clean. The semgrep unguarded-dialer rule was not independently re-run locally (no outbound client touched this span) and will confirm on the PR's own `sast.yml` run. No control flipped, no count change. |
| 1.29 | 2026-09-17 | `438bf17c` | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The v0.8.6 milestone-gate closure (issue #983), "Privacy & user rights" (export completeness, self-service account deletion, deliberate bidi/EXIF/CRLF input-handling decisions). Not a full re-pass — records the milestone's surfaces for §9's "new class" question. The gate text named three issues (#945, #970, #972); the milestone gained four more before closure — **#1121–#1124** (Android bugfixes, PR #1128) — the same drift #980 flagged for correction in pass 1.22; all seven are closed and covered here. **#970** (PR #1126) is the inverse of this report's usual "claim correction" pattern: `docs/privacy.md`'s "Export everything at any time" line predates this fix and was already the aspiration — the CSV silently omitted `LifeEvent`/`Gift`/`ConversationAgenda`/`CadencePolicy`/`ReminderCompletion`/non-food `Preference` categories, so the *code* was brought up to the existing claim rather than the claim brought down to the code. No route or persistence change; `export_controller.go` citations deliberately held stable by keeping the new sections in their own file. Verified: `TestExportData_ExtraSections`, `TestExportDataEmpty`, `TestExportDataUserScoping`, `TestExportCSV_IsFullFidelityBackup_UnlikeVCard` all pass. **#945** (PR #1125) adds three new documented positions to `asvs-l2.md`'s Documented positions section — **P9** (bidi/zero-width/confusable characters preserved verbatim, ADR-0016 decision #6, `internal/adversarial/bidi_confusables_decision_test.go`), **P10** (JPEG/PNG EXIF/GPS stripped at ingest via `attachments.StripImageMetadata`, HEIC a named gap, `attachments/exif_strip_test.go` + `controllers/attachment_real_db_test.go`), **P11** (outbound email CRLF hardening on `To`/`Subject` in `services/mailer.go`, defense-in-depth since no current caller passes anything else, `services/mailer_test.go`) — added to §7 below (now **eleven** written-down positions). None is a `partial` ASVS row (P11 is cited from the already-`satisfied` 5.2.3), so the 23-exception count is unchanged; found and fixed in this pass, though: `asvs-l2.md`'s own header (`Level claimed`) had been stuck at "26 documented exceptions" since PR #892 (pass 1.16) — #940's flip (PR #1036, pass 1.21) updated this report's running count to 23 but never touched that header line — and the "Documented positions" intro had been stuck at "Eight...decisions" since PR #896 (pass 1.17, P8's addition) since #945 added P9–P11 without updating it. Both are a claim-vs-reality drift of the same shape as the F-6/#861/#965/#961 line, just in the checklist's own headline rather than a single row. Corrected to **23** / **eleven** in-commit, matching this report's own running count and a fresh `citecheck` tally (194 satisfied, 23 partial, 38 not-applicable, 2 out-of-scope). **#972** (PR #1129) adds `DELETE /api/v1/account` — self-service account deletion, closing the gap where the admin-only `DeleteUser` path refused self-delete and the common single-user (necessarily sole-admin) deployment had no in-product erasure path, matching `docs/privacy.md`'s existing "Delete your account and all its data" claim for that case for the first time. **New authenticated route, not a new class:** declared in the live-router authorization matrix (`routes/authorization_matrix_test.go:270`, `classProtected`) and swept by the existing delete-cascade completeness test (`TestDeleteCascadeCoverage_DeleteOwnAccountSweepsEveryDeclaredUserTable`) against the same `deleteUserCascade` the admin path now shares — both mechanical §9 checks pass without modification, the pattern §9's route/entity rows exist for. Re-proof (password + live TOTP/recovery-code if 2FA enabled) matches this project's other self-service-action convention; no `audit_events` row (actor/target are the same row the cascade deletes), a server-log line instead (`TestDeleteOwnAccount_NoAuditEventPersisted`). Row **8.3.2** updated in-commit with the full citation. **#1121–#1124** (Android): #1121 (duplicate Nickname field) and #1123 (SMS backfill watermark paging) are client-only bug fixes with no server surface. #1122 (`ContactPhoneIndexBackfillWorker`) is a new periodic client-side worker that only calls the existing, already-authenticated `getContact` REST endpoint — no new route, no new persistence (no migration landed anywhere in this span, confirmed by `git diff --stat` against the prior release commit). #1124 is ADR-0019 (a recorded decision + a regression test asserting the SMS Inbox is never queried today, `_945`-style disposition) with the actual reconciliation worker deferred to follow-up #1127 — no new surface yet. **No new class of security-relevant surface across the whole milestone** — no new outbound integration, persistence target, or CI credential. §9's mechanical checks (`authorization_matrix_test.go`, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) and the full backend suite (`go build`/`go vet`/`gofmt -l`/`go test ./...`) pass on `438bf17c`; frontend `tsc --noEmit`, `src/i18n/locales.test.ts`, and `DeleteAccountSettings.test.tsx` (7 tests) pass. `go run ./cmd/citecheck`/`-drift`, `docscheck`, `governancecheck`, `releasegatecheck`, `depexceptions`, `deprecations` all clean; no new entry needed in `citation-drift.ignore`. The `Docs & security-doc citations` job is green on `438bf17c` (https://github.com/DrewBrunning/mycorrhizal-crm/actions/runs/35259910398/job/105332744393). No control flipped, no count change. |
| 2.0 | 2026-09-18 | `47358eb4` | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | **Full re-verification (Pass #2)** — the first since Pass #1/v0.6.1, superseding the stale header, claim and census that passes 1.1–1.29 left behind (issues #932 / #939). Every figure regenerated from `cmd/citecheck`: 23 partial (was 26), 194 satisfied (was 186/191), 1,061 path citations, 399 line ranges, 301 control rows. §7 corrected to 23 rows; the level claim qualified as a **self-assessment** in `asvs-l2.md`, `masvs-l1.md` and `SECURITY.md`; and `TestVerificationReportClaimsMatchCensus` added so the census and every level count are *asserted* against the parsed rows — the drift gate the report previously only claimed (issue #939). Full automated suite re-run green: backend 92 packages / 4,657 test functions, frontend 1,842 tests, `actionlint@v1.7.12`, citecheck/drift/docscheck/governancecheck/releasegatecheck/depexceptions/deprecations clean. §10 renumbered in ascending order and the duplicate `1.12` removed (issue #932). Co-landed threat-model work: the **operational/environmental-failure actor group** added and all ten #500 scenarios mapped to a row (issue #930), and the sync-gap, SHA-pin exception, cosign-identity, governance-cadence and import-citation claim drift corrected (issue #931). No control flipped. |
| 2.1 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The **v0.8.7 milestone-gate closure** (issue #986), "attestation & threat-model accuracy", and the standing "do the §8 pass at gate-close time" criterion this gate carries (issue #378 / #985). The milestone's substantive re-verification is the full **Pass #2** row immediately above, whose superseding header, claim, census and §7 already cover **#930** (operational/environmental-failure actor class added to `docs/security/threat-model.md:137-172`, all ten of #500's catastrophic scenarios mapped to a row), **#931** (the stale open-#512 sync gap, the SLSA `@v2.1.0` SHA-pin exception, the `promote-rc` cosign identity, the weekly/token-gated `governance-drift` cadence, and the wrong import-actor citation all corrected), **#932** and **#939**. This gate row records the two issues that reached `main` after Pass #2's verified commit `47358eb4`, neither of which is a security control: **#941** (PR #1135, merge `194ded9e`) pins the operator Docker Engine floor to the documented `23.0` in `docs/supported-versions.md:57` and replaces `cmd/docscheck`'s substring match with a structural read of the **Docker Engine** row (`checkDockerEngineFloor` / `dockerEngineRowFloor`, `backend/cmd/docscheck/main.go`, regression `TestDockerEngineFloorRegression`), so a competing `>= NN.N` now fails the docs gate — a documentation-accuracy fix, not security evidence; **#944** (PR #1136, merge `15838bf1`) qualifies the client-compatibility matrix's "every released client works with every released server" claim to servers **>= v0.6.0** in `docs/client-compatibility-policy.md` and `docs/versioning-policy.md` and adds `backend/internal/compatci/client_compat_floor_test.go`, which pins the matrix's lower bound to `database.SupportedUpgradeFloorTag` and to the Android `SERVER_BASELINE` / `MIN_SUPPORTED_SERVER_VERSION` constants. **No new class of security-relevant surface across the whole milestone** — no new route, client platform, outbound integration, authentication path, persistence target, or CI credential; the only new server-side artifact in the span is #941's `docscheck` structural check, a CI-side tool with no runtime or network exposure. §9's mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) and the `Docs & security-doc citations` job are green on the milestone tip `f1f495b7` (https://github.com/DrewBrunning/mycorrhizal-crm/actions/runs/35358445865/job/105643473918). No control flipped, no count change. |
| 2.2 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | Issues #955/#956 (root-secret rotation cadence, age visibility, support-window policy), a policy/visibility delta, not a full re-pass. **No control flipped; no count change.** Adds `docs/security/security-cadence.md`: the canonical rotation intervals for `JWT_SECRET_KEY`, `DATA_ENCRYPTION_KEY`, the release GitHub App key, and the Android signing keystore, the human-maintained last-rotated register, the signing-keystore custody/offline-backup posture (the signing key is deliberately **not** rotated on a schedule — replacing it breaks in-place upgrade), and the annual sensitive-resource access-list and support-window/EOL reviews. `cmd/securitycadence` + `.github/workflows/security-cadence.yml` raise the monthly overdue alarm; `TestScheduledWorkflowRunsTheCadenceCheck` (`cmd/securitycadence/main_test.go`) fails if the schedule or command binding is removed, and `TestRealRegisterParsesAndCoversEveryObligation` fails if a required secret class is dropped from the register. `SECURITY.md` now defines the support window and cherry-pick backport path from `1.0.0`, cross-referenced from `versioning-policy.md` and `supported-versions.md`. Age visibility: migration `000057` adds `data_encryption_keys.rotated_at`, `atrest.RotateMasterKey` stamps it (pinned by `TestReadKeyMaterialTimes_TracksRotation`), and `GET /api/v1/admin/diagnostics` reports the `at_rest_key` age row (pinned by `TestDiagnosticsAtRestKey`); both are cited on `asvs-l2.md` row **1.6.1**. The deep key-management verification is unchanged from Pass #2's row — this row records only the new policy, register, and age surfaces. §9's mechanical checks (`authorization_matrix_test.go`, the semgrep unguarded-dialer rule, `delete_cascade_coverage_test.go`, `citecheck`'s crypto-surface gate) and the `Docs & security-doc citations` job pass on the merge commit. |
| 2.3 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | Issue **#950**, not a full re-pass — the per-user resource-quota decision and its opt-in enforcement, plus the multi-user threat-model boundary it belongs to. **No new *class* of security-relevant surface:** no new route, client, outbound integration, authentication path, persistence target, or CI credential — this is request-handling logic on the existing create routes and four new config fields (`PER_USER_CONTACT_LIMIT`, `PER_USER_NOTE_LIMIT`, `PER_USER_RELATIONSHIP_EDGE_LIMIT`, `PER_USER_ATTACHMENT_QUOTA_MB`; all default `0` = unlimited, so existing single-operator deployments are unchanged, and the generated register/`docs/configuration-reference.md` picks them up). **New `backend/services/user_quota.go`** is the single per-user counter: each check is `WHERE user_id = ?` (never instance-wide), counts live rows only (a soft-deleted row is the undo window, not charged), and refuses with `507 INSUFFICIENT_STORAGE` naming the variable. Wired at `contact_controller.go:66`, `note_controller.go:35,86`, `relationship_edge_controller.go:67,217` (thin contacts created by an edge endpoint are counted too, inside the same transaction, so the edge rolls back with them) and `attachment_controller.go:134` (preflighted before the file reaches disk, so a refused upload leaves no row and no orphan file). `rows **11.1.3** and **12.1.3** are reworded with the new evidence but stay **partial** — the residual, stated in both rows and in the threat-model plan, is that bulk-ingestion paths (import confirm, CardDAV/CalDAV reconcile) are bounded per request rather than against the cumulative quota; **no count change, no control flip**. The threat model gains a **`## Multi-user instances`** section (data, resource and per-user-job isolation; the 1.0 plan) and a **peer-user resource-exhaustion** actor row, and `docs/supported-versions.md`'s isolation guarantee gains the quota bullet. Cross-user non-interference is pinned by `backend/services/user_quota_test.go` (`TestUserQuota_IsolationBetweenUsers`, plus soft-delete and DB-error cases) and `backend/controllers/user_quota_enforcement_test.go` (per-path 507 + isolation + no-orphan-on-refusal), both hand-verified to fail with the enforcement removed and with the `user_id` scope stripped. |
| 2.4 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | Issue #952 (adversarial-review finding: the audit hash chain is verified manually only), not a full re-pass — a single-control delta. The chain was tamper-EVIDENT by construction since #381, but the only verifier was the on-demand `cmd/audit-verify` (`make audit-verify`); the scheduled `CheckDBIntegrityScheduled` job ran the storage `PRAGMA`, data-invariant and FTS-consistency passes and never recomputed the chain, so a tampered audit row was undetected between incidents — a tamper-evident log nobody checks provides no detection. **`backend/services/db_integrity_service.go`** gains a fourth pass, `runAuditChainPass`, on the same `DB_INTEGRITY_CHECK_ENABLED` gate and schedule as the other three: it calls `models.VerifyAuditChain`, records the verdict under the new `models.CheckNameAuditChain` (`audit_chain_check`) operational-check-result, and fires the existing `db.integrity_check_failed` webhook with `kind: "audit_chain"` carrying the first gap's event id and message (the error branch, a check that cannot run at all, fires too — #921's shape). The `dbIntegrityCondition` "Database integrity" alert now reads that result alongside storage/data, so a subscriber wired to `alert.raised` is paged as well; **no new webhook event type** was needed, so the `WebhookInput` oneof and the frontend event list are untouched. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, persistence target, or CI credential; the pass is a read-only recomputation over the existing `audit_events` table. Row **7.3.3** gains the scheduled-verification citation in-commit. Tests: `services/db_integrity_service_test.go` (`TestCheckDBIntegrityScheduled_AuditChainCleanRecordsOK`, `TestCheckDBIntegrityScheduled_DetectsTamperedAuditChain`, `TestCheckDBIntegrityScheduled_AuditChainGapFiresWebhook`, `TestCheckDBIntegrityScheduled_AuditChainErrorFiresWebhook`) and the `audit_chain failure feeds the same DB-integrity alert` case in `services/alerting_service_test.go`, each hand-verified to fail with the pass unwired (the tamper shape matches `models/audit_chain_test.go`'s existing `TestAuditChain_DetectsContentEdit`). No control flipped, no count change. |
| 2.5 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The **v0.8.8 milestone-gate closure** (issue #987), "ongoing cadence & governance", and the standing "do the §8 pass at gate-close time" criterion this gate carries (issue #378 / #985 / #608). Three of the milestone's seven issues already carry their own rows above — **#955/#956** (2.2), **#950** (2.3), **#952** (2.4) — so this row records the remaining three and confirms the whole milestone. **#946** (PR #1141, merge `71f0c88a`) automates the reproducible pen-test harness: `.github/workflows/pentest-harness.yml` boots `docker-compose.pentest.yml` (the shipped all-in-one image, two-user `smoke` profile) weekly (Mondays 04:00 UTC, `:23-25`) and on a PR touching the harness, asserts the seeded admin's generated credentials actually authenticate (`:78-91`), then runs `backend/cmd/bolacheck`'s cross-account BOLA/IDOR sweep (`:93-97`) — so the environment cannot silently drift from the shipped image. The repeat-engagement cadence is written down ("at least once per minor release, and never more than six months apart", `docs/development/pentest-environment.md:124-147`) and the harness cannot drift back to being referenced by nothing, pinned by `TestPentestComposeIsExercisedOnSchedule` (`backend/cmd/pentestseed/compose_test.go:77`). **#949** (same PR) adds the ASVS calendar backstop the report's §9 named as the blind spot: `.github/workflows/asvs-staleness.yml` runs weekly (Mondays 07:00 UTC, `:22-24`) and calls `backend/cmd/asvsstale`, which alarms when the report's newest header or §10 date is older than 90 days and points back at §8 (`:53-84`) — a staleness *reminder*, with the full re-pass left a release obligation; `TestRealReportParses`/`TestRunStalePointsAtSection8` (`cmd/asvsstale/main_test.go`) pin the parse and the stale path. **#953** (PR #1149, merge `69f159c2`) adds the three missing release expectations: `backend/internal/residualrisk` + `cmd/residualrisk` emit the open accept-with-reason items, dependency-exception expiry and ASVS/MASVS exception counts into `release-metadata.json` under `residual_risk` (`.github/workflows/release.yml:660-698`, `TestReleaseWorkflowEmitsResidualRisk`); `docs/security/adversarial-deltas.md` + `backend/internal/adversarialdelta` + `cmd/adversarialdelta` require a per-release delta row for each security-relevant surface class a final release's diff touched, gated in `release.yml:491-539` with the recorded `ack_adversarial_delta` escape (`TestReleaseWorkflowRunsTheDeltaGate`, `TestLedgerExists`); and `backend/internal/releasenotes` now harvests a `## Security-relevant changes` block (`releasenotes.go:45`). §9 gains the per-release paragraph and `threat-model.md:361-367` records the convention. **No new class of security-relevant surface across the whole milestone** — no new route, client platform, outbound integration, authentication path, persistence target, or privileged CI credential. The one new schema object is migration `000057` (`data_encryption_keys.rotated_at`), a derived timestamp column on the already-inventoried `data_encryption_keys` row, recorded in row 2.2; the span's other changes — #1140's acceptance of a ZAP 40018 false positive in `zap/dast.ignore`, #1142/#1143's release resumability, #1148's APK-attach retry (which also carried the #955/#956 squash), #1150/#1151's in-flight fan-in gate-state fix — are workflow, script and test changes with no runtime or network surface. Verified on the branch: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...` green; `go run ./cmd/citecheck` clean with no new drift, `go run ./cmd/citecheck -drift` reviewed, and `docscheck`, `governancecheck`, `releasegatecheck`, `depexceptions`, `deprecations` all clean; no new `citation-drift.ignore` entry. The `Docs & security-doc citations` job is green on this pass's commit. No control flipped, no count change. |
| 2.6 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | **ADR 0021** (issues #1161/#1162/#1163/#1165/#1166) — a **release-process change**, not a re-pass over application code. The release path emulated cross-run dependencies by dispatching workflows and polling their check-runs (the source of the #543/#913/#1013/#1150 class); it now **composes** them: the new reusable `release-validate.yml` invokes the same check workflows the per-PR/schedule paths run, with real `needs:` dependencies, and both `release.yml` and `docker-publish.yml`'s `release-gate` call it, so "is this commit releasable" has one definition. `release.yml` is restructured into `preflight`/`validate`/`release`; `release-metadata.json` is now owned and attached by `docker-publish.yml` (removing the forward dependency where `release.yml` waited up to 120 minutes, re-minting the App token, for a Release a different workflow created); the `release-gate-state.sh`/`release-gate-decide.sh` poll helpers and their tests are deleted. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, or persistence target. **One CI-privilege change, recorded in §9:** the release composer is read-only at the top level, granting write scopes only where GitHub's static nested-permission validation requires them — `security-events: write` on the three SARIF-uploading scan call-jobs and on the two jobs that call the composer (the scans' upload steps are gated off when composed, so the scope is inert there) — and `release.yml`'s release job drops `actions: write` now that it dispatches nothing. The tag-push App token is unchanged. `releasegatecheck` now also fails if a `release_gate:true`/release-tier workflow loses `workflow_call`. No control flipped, no count change. |
| 2.7 | 2026-09-18 | (see PR) | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | Issue **#1164**, the remainder of ADR 0021's release-readiness work, not a re-pass over application code. `release.yml` now emits a durable **`release-readiness` artifact** before tagging — version, source commit/ref, migration version, the composer's **per-gate results** (`release-validate.yml` collects `needs.*.result` in a new `results` job), the ASVS/adversarial acknowledgement reasons, `fixture_registered`, and the residual-risk statement — and a re-dispatch **reads it at `preflight`** to decide *resume*, with the permanent `SupportedReleases` registration as a fail-closed consistency check and backstop (an artifact that records the fixture registered while the registry disagrees refuses the cut; if the artifact has aged out at its 90-day retention the git signal is used). `docker-publish.yml`'s `create-release` attaches the artifact to the Release. A `concurrency` group `release-<version>` serializes cuts, pinned by a new test (`backend/internal/releaseworkflow`, run by `releasegatecheck`) that also asserts the readiness upload. The `release-validate-decide.sh` helper and its test are deleted. **No new class of security-relevant surface** — no new route, client, outbound integration, authentication path, or persistence target. Only read-only `actions: read` is added (preflight/release/create-release) to fetch the artifact via the artifacts API. No control flipped, no count change. |
| 2.8 | 2026-09-19 | `16814a0b` | ASVS L2 with 23 documented exceptions; MASVS-L1 with 1 | The **v0.9.0 RC1 gate** (issue #503) — the standing §8 re-verification at gate-close, run against the released candidate. **No new class of security-relevant surface:** the milestone added no application surface; every change since Pass #2 (2.0) was release tooling (ADR 0021, issues #1161–#1185), documentation, or test infrastructure, plus the job-lock `SQLITE_BUSY` retry (#1176 / #1183) and Android macrobenchmark test code (#1179 / #1180) — the ADR 0021 CI-credential change is already recorded in row 2.6. **Automated half re-run clean:** `citecheck` (all citations resolve; 0 new drift; 1,088 path citations / 404 line ranges; crypto-surface 31 files, 18 V6-accounted, 13 ignored), `go test ./...` (96 packages; 4,770 `Test` + 8 `Fuzz` + 4 `Benchmark` across 667 files), `tsc --noEmit` + `vitest run` (1,842 tests / 207 files), `actionlint`. **The four manual audits (§4) re-confirmed:** A — 68 non-test controller files, 246 functions carrying a DB verb, the same 9 known-correct unscoped sites, no new IDOR; B — `TestCookieFlagPolicy` passes (the response-driven `Set-Cookie` enumeration, both `CookieSecure` settings); C — the `citecheck` crypto-surface gate is clean and the 15-registration unauthenticated route surface is unchanged; D — the error-envelope and panic-recovery tests resolve. §7's P1–P11 revisit triggers: none fired. One finding, **F-9** ([#1186](https://github.com/DrewBrunning/mycorrhizal-crm/issues/1186)): the `routes` package's tests flake because the process-wide auth rate limiter is not reset between them — test hygiene, filed, not a product defect. No control flipped, no count change. |
