docs(12-05): complete the Phase 12 security proof, coverage and gate plan
This commit is contained in:
@@ -0,0 +1,279 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 05
|
||||||
|
subsystem: testing
|
||||||
|
tags: [fonoteka, security, search-leak, fuzz, route-table, coverage, gate, lagoon, attach, tide, beachcomber, parity]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
provides: "12-01..12-04: lagoon.ValidateRequest, attach URLs/Thumb, tide multipart, beachcomber.SearchPage/RegisterEngine, Resolve/AccessibleBy/AlbumsAccessibleBy, SearchAlbums, the collection, household and album handlers on both auth groups, 99 ported parity routes"
|
||||||
|
- phase: 11-jobs-realtime-and-search-infrastructure
|
||||||
|
provides: "scripts/check-phase11.sh gate pattern (stages, --self-test, --named, --removal, --evidence) and 11-SECURITY-REVIEW.md format"
|
||||||
|
provides:
|
||||||
|
- "TestSearchLeak: poisoned fake engine through the real albums/search route on both groups (stale-moved, mis-scoped, soft-deleted, removed-editor and its resolve race, token-pin, short-page, recount, cap, engine-error), page ids and meta.total compared exactly"
|
||||||
|
- "TestRouteTablePhase12: one inv.scope per token route equal to routes.php, JWT-only routes absent from the token group, [0-9]+ id constraints, D-01 throttles on exactly their routes"
|
||||||
|
- "FuzzWriteEndpoints over all 41 Phase 12 write routes from the route table, Postgres column snapshots, 41 committed seeds (82 seed runs)"
|
||||||
|
- "TestPhase12Threats: 26 subtests, one per mitigated T-12 threat not covered by the dedicated tests"
|
||||||
|
- "Full unit coverage (>= 80%) of lagoon, lagoon/attach, tide, beachcomber, beachcomber/typesense, fonoteka classes, controllers/api, sm-user-plugin classes and updates"
|
||||||
|
- "scripts/check-phase12.sh with --self-test, --go, --parity, --named, --removal (25 anchor-exact mutations), --coverage, --evidence, --all"
|
||||||
|
- "12-SECURITY-REVIEW.md, validated 12-VALIDATION.md, API-01 and API-02 Complete"
|
||||||
|
affects: [13, 14, 12.2]
|
||||||
|
|
||||||
|
actuals:
|
||||||
|
tokens: 139512
|
||||||
|
tasks: 3
|
||||||
|
commits: 15
|
||||||
|
# Measured: summercms.go `git rev-list --count 37595fb..1f4e1e0` = 12, of which 6 are 12-05's
|
||||||
|
# (the other 6 are the concurrent Phase 12.2 planning session's docs commits on the same branch);
|
||||||
|
# fonoteka.go `git rev-list --count 3b8304e..f60c3af` = 8; sm-user-plugin c258e9f = 1.
|
||||||
|
# 6 + 8 + 1 = 15, before this SUMMARY's own commit.
|
||||||
|
plan_head_before: "summercms.go 37595fb6b2c446e65aea5e25d1703fb8d297dba4; fonoteka.go 3b8304ea8cf6fe06af0972066e40e36e3ef2fe9a"
|
||||||
|
plan_head_after: "summercms.go 1f4e1e01c40a71621db1d9b6bfaa1a23aa266990; fonoteka.go f60c3af06f573cc6f98c60363eeea0a747ef1a8b"
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Test-only beachcomber engine registered with RegisterEngine from _test.go files and selected by search.driver; it scripts ids/found/errors per call and records every Query"
|
||||||
|
- "Write-endpoint fuzz enumerates routes from surf's route table and diffs Postgres row snapshots against a per-route allowed-column set"
|
||||||
|
- "Removal harness: anchor must occur exactly once, the named test must fail on an assertion (build failure does not count), restore checked with cmp, refuses dirty files"
|
||||||
|
- "PHP truth tables recorded from WinterCMS/PHP 8 drive the validator and request-cast tests (in/not_in, (string) of floats, (int) casts, FILTER_VALIDATE_BOOLEAN, egulias e-mail domains)"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- scripts/check-phase12.sh
|
||||||
|
- .planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/fake_engine_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/ (41 seeds)
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/phase12_security_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/album_side_routes_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/handler_failures_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/handler_paths_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/phase12_classes_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_helpers_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/search_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase12_controllers_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/classes/user_groups_test.go (sm-user-plugin)
|
||||||
|
modified:
|
||||||
|
- modules/lagoon/attach/thumb.go
|
||||||
|
- modules/lagoon/validate_rules.go
|
||||||
|
- modules/lagoon/README.md
|
||||||
|
- docs/database/attachments.md
|
||||||
|
- modules/lagoon/validate_request_test.go
|
||||||
|
- modules/lagoon/validate_rules_test.go
|
||||||
|
- modules/lagoon/attach/url_test.go
|
||||||
|
- modules/tide/multipart_test.go
|
||||||
|
- modules/tide/normalize_upload_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/php_values.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/laravel_email.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go
|
||||||
|
- .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md
|
||||||
|
- .planning/REQUIREMENTS.md
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "An unusable stored original (missing, undecodable, over 4096x4096) makes attach Thumb store WinterCMS's broken-image picture and return its URL instead of an error, as File::makeThumb's catch branch does (T-12-16: one 100-byte PNG used to 500 every later listing)"
|
||||||
|
- "--removal is not part of --all: it edits tracked source and refuses dirty files, so it runs as its own stage; the review records all 25 RC rows"
|
||||||
|
- "RC-18 removes CreateAlbum's server-set collection_id rather than making collection_id fillable, because setAlbumField has no setter for it and the fillable change alone never reaches the database"
|
||||||
|
- "The security review is self-performed by the executor (08-10 precedent); every high mitigated threat must have a removal row, enforced by --evidence"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Phase gate per phase: scripts/check-phaseNN.sh with fail-closed detectors proven by --self-test"
|
||||||
|
- "One subtest per threat id (t.Run(\"T-12-NN\")) so the removal harness can target a single threat"
|
||||||
|
|
||||||
|
requirements-completed: [API-01, API-02]
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
- id: D1
|
||||||
|
description: "A poisoned search index leaks no album and no count to a JWT user or a pinned token (ROADMAP SC-3, D-18, D-19)"
|
||||||
|
requirement: API-02
|
||||||
|
verification:
|
||||||
|
- kind: integration
|
||||||
|
ref: "../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go#TestSearchLeak (-race)"
|
||||||
|
status: pass
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --removal RC-02, RC-03, RC-04"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D2
|
||||||
|
description: "Every token route carries PHP's one scope, JWT-only routes are absent from the token group, ids are constrained and throttles sit on exactly their routes (D-10, D-26)"
|
||||||
|
requirement: API-01
|
||||||
|
verification:
|
||||||
|
- kind: integration
|
||||||
|
ref: "../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go#TestRouteTablePhase12"
|
||||||
|
status: pass
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --removal RC-07, RC-08"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D3
|
||||||
|
description: "No write endpoint of Phase 12 changes a server-owned column whatever extra keys it receives (ROADMAP SC-5, C-02)"
|
||||||
|
requirement: API-02
|
||||||
|
verification:
|
||||||
|
- kind: integration
|
||||||
|
ref: "../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go#FuzzWriteEndpoints (82 seed runs plus 60 s fuzzing)"
|
||||||
|
status: pass
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --removal RC-18, RC-19"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D4
|
||||||
|
description: "Every mitigated T-12 threat has a named test that fails when its protection is removed"
|
||||||
|
requirement: API-01
|
||||||
|
verification:
|
||||||
|
- kind: integration
|
||||||
|
ref: "../fonoteka.go/plugins/golem15/fonoteka/phase12_security_test.go#TestPhase12Threats"
|
||||||
|
status: pass
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --removal (25/25 fail as required) and --evidence"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D5
|
||||||
|
description: "Phase 12 packages in both repositories at or above 80% statement coverage"
|
||||||
|
verification:
|
||||||
|
- kind: unit
|
||||||
|
ref: "scripts/check-phase12.sh --coverage (lowest: tide 81.4%, controllers/api 81.7%)"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D6
|
||||||
|
description: "Fail-closed Phase 12 gate"
|
||||||
|
verification:
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --self-test && scripts/check-phase12.sh --all (phase12 all passed, 7 min)"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D7
|
||||||
|
description: "Security review, validation sign-off and requirement traceability"
|
||||||
|
verification:
|
||||||
|
- kind: other
|
||||||
|
ref: "scripts/check-phase12.sh --evidence"
|
||||||
|
status: pass
|
||||||
|
human_judgment: true
|
||||||
|
rationale: "The review text is written by the executor itself; a human should read 12-SECURITY-REVIEW.md before /gsd-verify-work closes the phase"
|
||||||
|
|
||||||
|
duration: 2h15m
|
||||||
|
completed: 2026-10-02
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 05: Phase 12 Security Proof, Unit Coverage and Gate Summary
|
||||||
|
|
||||||
|
**Poisoned-index leak test, route-table scope test, a 41-route write fuzz and one test per T-12 threat, each proven by 25 anchor-exact removal mutations, plus 80%+ coverage in nine packages and a fail-closed `check-phase12.sh` gate; six parity and safety bugs found and fixed on the way.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** about 2h15m (including one interruption, see below)
|
||||||
|
- **Started:** 2026-10-02T12:43:34Z (after the 12-04 SUMMARY commit)
|
||||||
|
- **Completed:** 2026-10-02T14:57Z
|
||||||
|
- **Tasks:** 3/3
|
||||||
|
- **Files modified:** 41 code/test files and 41 fuzz seeds across both repositories and the sm-user-plugin submodule, plus 3 planning docs
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- `TestSearchLeak` drives the real `albums/search` route on both auth groups with a scripted test-only engine. It shows that stale, mis-scoped, soft-deleted, removed-editor and other-collection ids never show up in `data` or in `meta.total`/`last_page`. The recount pages by 250 and stops at 1000, and an engine error falls back to SQL.
|
||||||
|
- `TestRouteTablePhase12` checks the assembled router against a table of every Phase 12 route transcribed from routes.php (with line numbers). It covers scopes, JWT-only absences, `[0-9]+` constraints and throttles.
|
||||||
|
- `FuzzWriteEndpoints` gets its 41 write routes from the route table and sends every server-owned key with hostile values. It snapshots Postgres rows before and after and allows only each route's own columns to change. There is one committed seed per route, and 60 s of fuzzing came back clean.
|
||||||
|
- `TestPhase12Threats` has 26 subtests named by threat id. Together with the dedicated tests above, every mitigated T-12 threat has a named test.
|
||||||
|
- Unit coverage, framework: beachcomber 84.3%, beachcomber/typesense 95.9%, lagoon 84.4%, lagoon/attach 87.7%, tide 81.4%.
|
||||||
|
- Unit coverage, application: fonoteka classes 83.6%, controllers/api 81.7%, sm-user-plugin classes 88.7%, sm-user-plugin updates 86.2%.
|
||||||
|
- `scripts/check-phase12.sh`: `--all` passes (vet and tests in both repos, parity 99 ported and 0 failing, recorded 171/171, named tests, coverage, evidence). `--removal` reports all 25 RC mutations failing their named test, and both trees are clean afterwards. `--self-test` shows that every detector fails closed.
|
||||||
|
- 12-SECURITY-REVIEW.md, the validated 12-VALIDATION.md (`nyquist_compliant: true`, no pending rows), and API-01 and API-02 set to Complete.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: poisoned search index leak test** (fonoteka.go): `7962bfc` (test)
|
||||||
|
2. **Task 2: token scopes, write-endpoint fuzz, T-12 threat tests** (fonoteka.go): `e4122ae` (test)
|
||||||
|
3. **Task 3: unit coverage, gate, sign-off**
|
||||||
|
- summercms.go `1307060` fix: broken-image thumbnail for an unusable original (T-12-16)
|
||||||
|
- summercms.go `36983bc` fix: Laravel in/not_in on array values
|
||||||
|
- summercms.go `3ac1d64` fix: float to string with PHP's 14-digit precision
|
||||||
|
- summercms.go `6e30624` test: framework packages to full unit coverage
|
||||||
|
- summercms.go `6f4386c` chore: the fail-closed Phase 12 gate
|
||||||
|
- fonoteka.go `64f5496` fix: PHP 8 int/string/numeric casts
|
||||||
|
- fonoteka.go `817867d` fix: egulias domain part in the Laravel email rule
|
||||||
|
- fonoteka.go `2869747` fix: Laravel int/bool query parameters
|
||||||
|
- fonoteka.go `40b2c65` test: album_added recipients matched by the payload's album id
|
||||||
|
- fonoteka.go `1ac1911` test: fonoteka classes and API handlers to full unit coverage
|
||||||
|
- sm-user-plugin `c258e9f` test: user group codes and class helpers; fonoteka.go `f60c3af` chore: bump the submodule
|
||||||
|
- summercms.go `1f4e1e0` docs: security review, validation sign-off, API-01/API-02
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
Listed in the `key-files` frontmatter. The production changes are limited to `modules/lagoon/attach/thumb.go`, `modules/lagoon/validate_rules.go` (plus its README and `docs/database/attachments.md`), and in fonoteka.go `classes/php_values.go`, `classes/laravel_email.go` and `controllers/api/request.go`. Each change is its own fix commit with a test that fails without it.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
See `key-decisions`. The most significant: when `attach.File.Thumb` gets an unusable original, it now follows WinterCMS and stores the broken-image picture instead of returning an error.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] A header-only 30000x30000 PNG made every later listing answer 500 (T-12-16)**
|
||||||
|
- **Found during:** Task 2 (T-12-16 subtest)
|
||||||
|
- **Fix:** `Thumb` logs the reason, stores `attach.BrokenImagePNG` and returns its URL
|
||||||
|
- **Files:** modules/lagoon/attach/thumb.go, README, docs/database/attachments.md
|
||||||
|
- **Verification:** TestThumbBrokenSourceServesPlaceholder (RED: three errors), TestPhase12Threats/T-12-16
|
||||||
|
- **Commit:** summercms.go 1307060
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] `in`/`not_in` on arrays diverged from Laravel**
|
||||||
|
- **Found during:** Task 3 (validator truth table)
|
||||||
|
- **Fix:** follow `array_diff` and `!validateIn`
|
||||||
|
- **Files:** modules/lagoon/validate_rules.go
|
||||||
|
- **Verification:** TestValidateRulesMatchLaravel (162 recorded cases)
|
||||||
|
- **Commit:** summercms.go 36983bc
|
||||||
|
|
||||||
|
**3. [Rule 1 - Bug] Float-to-string cast used 17 digits instead of PHP's 14**
|
||||||
|
- **Found during:** Task 3
|
||||||
|
- **Files:** modules/lagoon/validate_rules.go
|
||||||
|
- **Verification:** TestPHPFloatStringMatchesPHPCast
|
||||||
|
- **Commit:** summercms.go 3ac1d64
|
||||||
|
|
||||||
|
**4. [Rule 1 - Bug] PHP 8 (int), is_numeric and float-string casts diverged**
|
||||||
|
- **Found during:** Task 3
|
||||||
|
- **Files:** classes/php_values.go
|
||||||
|
- **Verification:** TestPHPValueCasts (RED: nineteen values)
|
||||||
|
- **Commit:** fonoteka.go 64f5496
|
||||||
|
|
||||||
|
**5. [Rule 1 - Bug] The Laravel email rule's domain part diverged from egulias**
|
||||||
|
- **Found during:** Task 3
|
||||||
|
- **Files:** classes/laravel_email.go
|
||||||
|
- **Verification:** TestValidLaravelEmailRFC
|
||||||
|
- **Commit:** fonoteka.go 817867d
|
||||||
|
|
||||||
|
**6. [Rule 1 - Bug] `per_page=1e2` and `boolean()` parsing diverged from Laravel**
|
||||||
|
- **Found during:** Task 3
|
||||||
|
- **Files:** controllers/api/request.go
|
||||||
|
- **Verification:** TestRequestCasts
|
||||||
|
- **Commit:** fonoteka.go 2869747
|
||||||
|
|
||||||
|
**7. [Rule 1 - Bug] Flaky test: album_added recipients were matched by position, not by the payload's album id**
|
||||||
|
- **Found during:** Task 3 (coverage runs)
|
||||||
|
- **Commit:** fonoteka.go 40b2c65
|
||||||
|
|
||||||
|
**Total deviations:** 7 auto-fixed (Rule 1). **Impact on plan:** each fix is needed for API parity or safety and is pinned by a test that fails without it. No scope creep and no new dependency.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- **Interruption:** an API usage limit stopped the first executor in the middle of Task 3, while it was running `scripts/check-phase12.sh --all`. At that point all code commits existed, and the security review, validation file and REQUIREMENTS.md edits were still uncommitted. A continuation executor checked the commits and reviewed the uncommitted docs. It re-ran `--all` (passed, 7 min) and `--removal` (25/25 fail as required, 3 min, both trees clean afterwards). It then committed the docs as `1f4e1e0` and wrote this summary. No code was redone.
|
||||||
|
- **sm-user-plugin not pushed:** the plan asked for the submodule's master to be pushed. This run was told not to push, so `plugins/golem15/user` is still 3 commits ahead of origin/master (c65ab3c, 8697007, c258e9f). fonoteka.go's pointer `f60c3af` refers to c258e9f, so the submodule has to be pushed before anyone else clones fonoteka.go.
|
||||||
|
- **RC-04 is partial by design:** with `AlbumsAccessibleBy` removed from `ScopedAlbums`, stale-moved, mis-scoped and soft-deleted still pass, because the active-collection filter and GORM's `deleted_at` filter still exclude those rows. Only token-pin and removed-editor fail. This is documented in the review.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None. Push sm-user-plugin master when convenient (see above).
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
Phase 12 is complete: all 5 plans have summaries, the gate is green, and API-01 and API-02 are Complete. Next steps are `/gsd-verify-work 12`, including a human read of 12-SECURITY-REVIEW.md and the manual-only fixture-recording row in 12-VALIDATION.md. Phase 12.2 is being planned in a parallel session.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 12-p-ytarium-api-collections-and-albums*
|
||||||
|
*Completed: 2026-10-02*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Files exist: scripts/check-phase12.sh, 12-SECURITY-REVIEW.md, 12-VALIDATION.md, search_leak_test.go, fake_engine_test.go, routes_table_phase12_test.go, write_endpoints_fuzz_test.go, phase12_security_test.go, 41 fuzz seeds
|
||||||
|
- Commits exist: 1307060 36983bc 3ac1d64 6e30624 6f4386c 1f4e1e0 (summercms.go); 7962bfc e4122ae 64f5496 817867d 2869747 40b2c65 1ac1911 f60c3af (fonoteka.go); c258e9f (sm-user-plugin)
|
||||||
|
- Acceptance: t.Run count in search_leak_test.go 21 (>= 9); T-12 subtests 26 (>= 15); RegisterEngine only in _test.go; nyquist_compliant true 1, TBD rows 0; T-12 ids in review equal the union of the plan registers; API-01/API-02 Complete; nine coverage lines >= 80%
|
||||||
Reference in New Issue
Block a user