Promote dev to main: security & code-review remediation batch - #70
Merged
Conversation
…us build) (#18) * ci: publish scrye image to Docker Hub on tagged releases and dev pushes Add .github/workflows/publish.yml with two independent publishing paths: - semver tags (v*.*.*) whose commit is on main build the multi-arch (amd64/arm64) image and push <dockerhub-user>/scrye:<version> + :latest - pushes to dev push the moving <dockerhub-user>/scrye:dev continuous-build tag Extract the multi-arch build (QEMU + Buildx + build-push against docker/Dockerfile) into a reusable .github/actions/build-image composite action and refactor ci.yml's multi-arch build-check to consume it, so the build is defined in one place. Publishing uses the DOCKERHUB_USERNAME/ DOCKERHUB_TOKEN repo secrets; ci.yml still never publishes. Update CLAUDE.md and docs/PLAN.md (locked decision 0.6, §9.1, §13, Deviations) and add a CONTRIBUTING.md Releasing section describing both paths. * ci: gate multi-arch build-check to main pushes and PRs to main The image-multiarch job's arm64 leg builds the whole Dockerfile under QEMU emulation, which on a cold type=gha cache takes hours. Only main-scoped runs reliably restore a warm arm64 cache; dev-based PRs rebuild from scratch every time. Gate the check to main pushes and PRs whose base is main. Multi-arch buildability stays proven for dev by publish.yml (builds amd64+arm64 on every dev push and release tag), and dev PRs still run the fast amd64-only image build + dogfood self-scan, so no coverage is lost.
The :dev Docker Hub publish was triggered by on: push: branches: [dev], which fired on any commit reaching the dev ref — including conflict- resolution pushes to an open, unmerged promotion PR. Re-scope it to on: pull_request: types: [closed] with base dev, gated on pull_request.merged == true, and build the merged commit (merge_commit_sha). The :dev tag now publishes only when a PR is actually merged into dev. The tagged-release path (v*.*.* on main) is unchanged. Sync docs/PLAN.md (§0.6 + Deviations entry) and the CONTRIBUTING.md Releasing section to describe the merged-PR trigger.
Comprehensive report-only audit covering infrastructure/deployment, backend security, scanner orchestration, API/data/performance, frontend, feature completeness against docs/PLAN.md, and the previously-logged known limitations. Findings are stably ID'd with file:line references, CONFIRMED/PLAUSIBLE confidence markers, failure scenarios, and fix directions, plus a prioritized action list.
…webhook URLs (#23) Addresses the P0 tier of docs/reviews/full-audit-2026-07-05.md §10: - QUA-1: cap API-token minting against the caller's effective (token-capped) role, not the owner account's role, so a low-privilege token belonging to an admin can no longer mint an admin token. Adds a regression test. - API-2: run database restore (scrypt + full-DB rebuild) in a threadpool so /healthz stays responsive and the container can't be killed mid-restore. - API-3: chunked executemany restore inserts; yield_per streaming build; drop the redundant bundle re-parse; log + document the in-memory size ceiling. - API-10: raw-artifact files don't travel in a bundle, so exclude the artifacts table from the dump and clear it on restore (no dangling file references). - API-11: refuse restore (409) while a scan is queued or running. - SEC-1: treat a generic webhook URL as a write-only credential (encrypted, masked on read) like Discord; frontend renders it as a password field. Deviation logged in docs/PLAN.md; README backup section updated.
* perf(worker,api): P1 audit remediation — off-loop heavy work + bounded memory Addresses the P1 tier of docs/reviews/full-audit-2026-07-05.md §10 (availability and performance under real data volume): - API-5: off-load the scan worker's result persistence (10k+ findings flush + raw-JSON write) to a thread via anyio.to_thread, completing the systemic "no synchronous heavy work on the event loop" fix begun in P0 (API-2/3). - SCN-1: cap captured scanner stdout (SCRYE_SCANNER_MAX_OUTPUT_BYTES, default 512 MiB); output past the budget kills the child and fails the scan instead of buffering unbounded JSON. - API-4: read SBOM/backup uploads through read_upload_capped so an oversized body is rejected by reported size / chunked read, never fully buffered first. - API-7: dashboard/metrics load only needed columns per target (load_only) and serve from a short process-wide TTL cache, cleared on app startup and in tests. - API-1: eager-load scan tags (selectinload) in the two list endpoints. - API-15/API-6: run the maintenance tick (schedules + retention) off the event loop and batch retention deletes into one DELETE ... WHERE id IN (...). New tests cover the output cap and upload cap; the dashboard cache TTL/reset is tested. Deviation logged in docs/PLAN.md; .env.example regenerated. * ci,fix(config,compose): P2 audit remediation — supply chain + deployment hardening Addresses the P2 tier of docs/reviews/full-audit-2026-07-05.md §10: - SCN-3: parse the documented comma-separated env form for cors_origins and filesystem_scan_roots (NoDecode + a before-validator), so the filesystem-scan enable switch (SCRYE_FILESYSTEM_SCAN_ROOTS=/path) no longer fails at startup. Adds env-parsing tests. - INF-1: add .github/dependabot.yml (github-actions ecosystem, weekly, grouped). SHA-pinning each `uses:` needs current action SHAs, which this environment's egress policy blocks from resolving/verifying — flagged for follow-up rather than pinning to an unverified SHA (would risk red CI). - INF-3: align CLAUDE.md §6's :dev wording with the implemented merged-PR-into-dev trigger (doc alignment; no behavior change). - INF-2: document the fork-PR :dev publish limitation in publish.yml as an accepted trade-off; the push-based alternative is a §6 locked-decision change left for a deliberate call. - INF-4: document the trivy-server root exception (upstream image ships no non-root USER; mitigations noted), per the audit's accepted alternative. - INF-5: add a small tmpfs:[/run] to docker-socket-proxy (HAProxy needs a writable /run under read_only), with a live-verify note. Deviation logged in docs/PLAN.md. * feat(scanners,docs): P3 audit remediation — wire dead Settings knobs + README truth Addresses the P3 tier of docs/reviews/full-audit-2026-07-05.md §10 (feature gaps that mislead users): - FEAT-6 (QUA-3): apply the stored Grype ignore config at scan time — a new grype_policy module materializes the YAML into tmpfs and the worker passes it to Grype via a `-c` config flag (mirroring the Trivy policy path). - FEAT-7 (QUA-3): the New Scan form prefills its severity filter and ignore-unfixed toggle from GET /settings/scanners so instance defaults take effect instead of being overridden by hardcoded form values. - FEAT-4 (QUA-3): the maintenance tick honors auto_update_db + interval, running `trivy image --download-db-only` and `grype db update` best-effort when due. - DOC-1/2/5 + FEAT-1/2/3/8: README aligned with reality — Docker Hub publishing is in scope; uploaded image-tar, Docker-env multi-select scan, and filesystem-archive upload are marked not-implemented; VEX/.trivyignore are global (not per-scan); the ECR/GCR/ACR helper-binaries caveat is stated. - FEAT-5/FEAT-10: offline DB import and admin bulk secret re-encryption are listed as not-yet-implemented; the key-rotation README claim is corrected. New tests cover the Grype config flag/materialization and the DB-update tick. Deviation logged in docs/PLAN.md. * fix(frontend): P4 audit remediation — session expiry, UTC times, restore label, RBAC gating Addresses the P4 tier of docs/reviews/full-audit-2026-07-05.md §10 (frontend correctness / UX): - FE-1: the API client emits an auth-invalidated event on any 401 and AuthContext drops to the login screen, instead of leaving a stale authenticated shell whose every action fails. - FE-3: a shared lib/dates.ts (parseUtc/formatWhen) renders backend naive-UTC timestamps; Account/Backups/Scheduled-scans stop showing UTC as local, and the ScanDetail/Scans private helpers are de-duplicated onto it. - FE-4: BackupsPanel's restore file uses useState (not useRef) so the selected file name re-renders on the destructive restore flow. - FE-5: ScheduledScansPanel constrains the scanner Select by target type (SCANNERS_FOR matrix + auto-correct) and gates Add/Run/Delete behind an operator/admin check; the /settings route is now guarded (viewers → /). Verified with tsc, ESLint, Prettier, and a clean vite build (no frontend test runner yet — FE-10 deferred to P5). Deviation logged in docs/PLAN.md. * fix(frontend): track src/lib/dates.ts (was hidden by the Python lib/ gitignore) The shared date helper added for FE-3 lives under frontend/src/lib/, which the generic Python-oriented `lib/` rule in .gitignore silently excluded — so the file was never committed and CI's fresh checkout failed the frontend build (and the image build) with "Cannot find module '../../lib/dates'". Add a .gitignore exception for the frontend source lib directory and commit the file. * fix(backup,tests): P5 audit remediation — envelope KDF params, migration drift test, deviation log Addresses the P5 tier of docs/reviews/full-audit-2026-07-05.md §10 (maintainability, process, long tail): - item (g): backup restore derives the passphrase key from the bundle's advertised scrypt params (kdf.n/r/p) instead of the module constants, so a bundle written under a different (e.g. older) work factor still restores. derive_key / passphrase_cipher take explicit, validated n/r/p; restore passes the recorded values. - QUA-23: new tests/test_migrations.py runs the real Alembic chain to head against a throwaway DB and asserts the tables/columns match Base.metadata (the rest of the suite builds via create_all). alembic/env.py now respects a caller-provided URL. Deviation-logging debt recorded in docs/PLAN.md (required regardless of fix): FE-2 (hand-rolled API client), INF-10 (HIGH/CRITICAL dogfood floor), API-12 (created_at vs started_at index), FEAT-4 (DB-schedule actuation). QUA-4/QUA-9, QUA-16, and FE-10 are explicitly deferred with rationale. * docs: defer INF-2 explicitly until the repo goes public Per user decision (2026-07-05): keep the merged-PR-only :dev publish trigger for now — while the repo is private, fork-based contributions can't happen, so the fork-secrets gap can't be triggered. Record in docs/PLAN.md that INF-2 must be revisited specifically before the repo is made public, since that is the event that enables fork PRs (and therefore the actual bug). INF-3's CLAUDE.md wording stays matched to the current trigger.
Add guidance for landing multi-PR stacked batches: retarget each child PR's base to the true target branch immediately after its parent merges, re-state the full merge procedure before each merge rather than once per batch, and verify the target branch's actual content after the batch is reported complete instead of assuming merge order alone propagates changes through the stack.
) * ci: batch dev image to a nightly GHCR build; trim per-PR CI minutes Restructure dev-image publishing and cut CI-minute usage. - Add .github/workflows/dev-nightly.yml: build the dev branch multi-arch once nightly (04:00 UTC) + manual dispatch and push the moving ghcr.io/iamgroot60/scrye:dev via the built-in GITHUB_TOKEN. Scheduled runs skip when dev has no new commits in 24h. - publish.yml is now release-only: drop the merged-PR :dev job and its pull_request trigger. Docker Hub (<dockerhub-user>/scrye) is referenced only by the release path. - Split registries by role: Docker Hub for tagged releases, GHCR for dev. - Resolve audit INF-2: a schedule trigger is not PR-triggered, so the fork-withheld-secrets gap no longer applies. - ci.yml minute reduction: run the two informational scanner reports on pushes to main only (dev PRs keep just the gate scans); add a cache-scope input to the build-image action and partition the GHA cache (amd64-ci vs multiarch vs dev-multiarch) so amd64-only and multi-arch builds stop evicting each other. - Update CLAUDE.md §6, docs/PLAN.md §0.6 + Deviations, README, and CONTRIBUTING to the two-registry model; remove the obsolete dev-publish smoke-test doc. * docs: prefer read-only default Actions permissions with per-workflow packages:write An explicit permissions: block overrides the repo-level "Workflow permissions" default, so GHCR push does not require raising the repo-wide default to read/write. Recommend keeping the restrictive read-only default and letting dev-nightly.yml declare its own contents:read + packages:write, matching the least-privilege posture. Update docs/PLAN.md and CONTRIBUTING.md accordingly.
…unts, parallel scanner downloads (#34) The image CI work was dominated by the multi-arch build check, whose arm64 leg runs the whole Dockerfile under QEMU emulation. CI logs showed it rebuilding cold every run (0 cached layers): the deliberately-partitioned `type=gha` cache scope it uses (`multiarch`) is only written on rare main/release events, so its entries age out between runs and the emulated arm64 layers get re-executed from scratch instead of restored. Apply the fixes consistently across all four build paths without weakening the supply-chain posture (scanner checksum verification, digest-pinned bases, and the non-root hardened final stage are unchanged): - Cross-seed the GHA cache scopes. Each build path still WRITES exactly one scope (keeping the 10 GB budget partitioning), but now also READS the frequently-warm sibling scope. The shared build-image action gains an `extra-cache-scopes` input (cache-from = primary + extras, cache-to = primary only). image-multiarch and the release build read the nightly's warm `dev-multiarch`; the nightly reads `multiarch`; the amd64 dogfood build reads `dev-multiarch` for warm amd64 layers. - Persist pip/npm download caches with BuildKit cache mounts so an unchanged dependency isn't re-fetched when its install layer rebuilds; drop PIP_NO_CACHE_DIR (the cache lives in the mount, not the image layer). - Parallelize the trivy/grype/syft download+verify+extract pipelines (each in a background subshell joined by `wait`); a checksum mismatch in any still fails the build via `wait` under `set -e`. Documented in docs/PLAN.md § Build performance (with the do-not-undo invariants and a per-path before/after) and a guardrail rule in CLAUDE.md.
…ions bumps (#33) Reconciles dev with main after the dev->main promotion (#32, squash-merged) and the Dependabot github-actions group bump (#33) that landed directly on main. The promotion squash re-introduced main's older copy of already-promoted dev work, which conflicts with dev's newer versions (the #34 build-performance changes); all such conflicts are resolved in favour of dev. The only content this merge actually brings into dev is #33's action version bumps (checkout v7, setup-python v6, setup-node v6, setup-buildx v4, build-push v7, login v4). After this merge main is an ancestor of dev, so dev no longer shows as behind.
…ev (#35) The dev/main release model (squash-merged promotion PRs, plus Dependabot targeting the default branch main) leaves dev showing commits 'behind' main after each release. Two coupled changes stop that recurring: - .github/dependabot.yml: set target-branch to dev so github-actions bumps open against the integration branch instead of landing on main and never reaching dev. Removes the avoidable drift source. - CLAUDE.md and CONTRIBUTING.md: document a back-merge step — after each dev->main promotion (and after any commit that lands on main directly), merge main into dev, resolving squash-divergence conflicts in favour of dev. Handles the unavoidable promotion-squash case. See docs/PLAN.md § Deviations (2026-07-07) for the rationale and the one-time reconciliation performed alongside this change.
…rge process (#36) (#37) * ci: publish Scrye image to Docker Hub (tagged releases + dev continuous build) (#18) * ci: publish scrye image to Docker Hub on tagged releases and dev pushes Add .github/workflows/publish.yml with two independent publishing paths: - semver tags (v*.*.*) whose commit is on main build the multi-arch (amd64/arm64) image and push <dockerhub-user>/scrye:<version> + :latest - pushes to dev push the moving <dockerhub-user>/scrye:dev continuous-build tag Extract the multi-arch build (QEMU + Buildx + build-push against docker/Dockerfile) into a reusable .github/actions/build-image composite action and refactor ci.yml's multi-arch build-check to consume it, so the build is defined in one place. Publishing uses the DOCKERHUB_USERNAME/ DOCKERHUB_TOKEN repo secrets; ci.yml still never publishes. Update CLAUDE.md and docs/PLAN.md (locked decision 0.6, §9.1, §13, Deviations) and add a CONTRIBUTING.md Releasing section describing both paths. * ci: gate multi-arch build-check to main pushes and PRs to main The image-multiarch job's arm64 leg builds the whole Dockerfile under QEMU emulation, which on a cold type=gha cache takes hours. Only main-scoped runs reliably restore a warm arm64 cache; dev-based PRs rebuild from scratch every time. Gate the check to main pushes and PRs whose base is main. Multi-arch buildability stays proven for dev by publish.yml (builds amd64+arm64 on every dev push and release tag), and dev PRs still run the fast amd64-only image build + dogfood self-scan, so no coverage is lost. * ci(publish): scope :dev publish to merged PRs into dev The :dev Docker Hub publish was triggered by on: push: branches: [dev], which fired on any commit reaching the dev ref — including conflict- resolution pushes to an open, unmerged promotion PR. Re-scope it to on: pull_request: types: [closed] with base dev, gated on pull_request.merged == true, and build the merged commit (merge_commit_sha). The :dev tag now publishes only when a PR is actually merged into dev. The tagged-release path (v*.*.* on main) is unchanged. Sync docs/PLAN.md (§0.6 + Deviations entry) and the CONTRIBUTING.md Releasing section to describe the merged-PR trigger. * test: add throwaway marker to smoke-test the :dev publish on merge to dev (#21) * docs: add full-repository audit report (2026-07-05) (#22) Comprehensive report-only audit covering infrastructure/deployment, backend security, scanner orchestration, API/data/performance, frontend, feature completeness against docs/PLAN.md, and the previously-logged known limitations. Findings are stably ID'd with file:line references, CONFIRMED/PLAUSIBLE confidence markers, failure scenarios, and fix directions, plus a prioritized action list. * fix(security,backup): P0 audit remediation — token minting, restore, webhook URLs (#23) Addresses the P0 tier of docs/reviews/full-audit-2026-07-05.md §10: - QUA-1: cap API-token minting against the caller's effective (token-capped) role, not the owner account's role, so a low-privilege token belonging to an admin can no longer mint an admin token. Adds a regression test. - API-2: run database restore (scrypt + full-DB rebuild) in a threadpool so /healthz stays responsive and the container can't be killed mid-restore. - API-3: chunked executemany restore inserts; yield_per streaming build; drop the redundant bundle re-parse; log + document the in-memory size ceiling. - API-10: raw-artifact files don't travel in a bundle, so exclude the artifacts table from the dump and clear it on restore (no dangling file references). - API-11: refuse restore (409) while a scan is queued or running. - SEC-1: treat a generic webhook URL as a write-only credential (encrypted, masked on read) like Discord; frontend renders it as a password field. Deviation logged in docs/PLAN.md; README backup section updated. * Land P1–P5 audit remediation on dev (P0 already merged via #23) (#29) * perf(worker,api): P1 audit remediation — off-loop heavy work + bounded memory Addresses the P1 tier of docs/reviews/full-audit-2026-07-05.md §10 (availability and performance under real data volume): - API-5: off-load the scan worker's result persistence (10k+ findings flush + raw-JSON write) to a thread via anyio.to_thread, completing the systemic "no synchronous heavy work on the event loop" fix begun in P0 (API-2/3). - SCN-1: cap captured scanner stdout (SCRYE_SCANNER_MAX_OUTPUT_BYTES, default 512 MiB); output past the budget kills the child and fails the scan instead of buffering unbounded JSON. - API-4: read SBOM/backup uploads through read_upload_capped so an oversized body is rejected by reported size / chunked read, never fully buffered first. - API-7: dashboard/metrics load only needed columns per target (load_only) and serve from a short process-wide TTL cache, cleared on app startup and in tests. - API-1: eager-load scan tags (selectinload) in the two list endpoints. - API-15/API-6: run the maintenance tick (schedules + retention) off the event loop and batch retention deletes into one DELETE ... WHERE id IN (...). New tests cover the output cap and upload cap; the dashboard cache TTL/reset is tested. Deviation logged in docs/PLAN.md; .env.example regenerated. * ci,fix(config,compose): P2 audit remediation — supply chain + deployment hardening Addresses the P2 tier of docs/reviews/full-audit-2026-07-05.md §10: - SCN-3: parse the documented comma-separated env form for cors_origins and filesystem_scan_roots (NoDecode + a before-validator), so the filesystem-scan enable switch (SCRYE_FILESYSTEM_SCAN_ROOTS=/path) no longer fails at startup. Adds env-parsing tests. - INF-1: add .github/dependabot.yml (github-actions ecosystem, weekly, grouped). SHA-pinning each `uses:` needs current action SHAs, which this environment's egress policy blocks from resolving/verifying — flagged for follow-up rather than pinning to an unverified SHA (would risk red CI). - INF-3: align CLAUDE.md §6's :dev wording with the implemented merged-PR-into-dev trigger (doc alignment; no behavior change). - INF-2: document the fork-PR :dev publish limitation in publish.yml as an accepted trade-off; the push-based alternative is a §6 locked-decision change left for a deliberate call. - INF-4: document the trivy-server root exception (upstream image ships no non-root USER; mitigations noted), per the audit's accepted alternative. - INF-5: add a small tmpfs:[/run] to docker-socket-proxy (HAProxy needs a writable /run under read_only), with a live-verify note. Deviation logged in docs/PLAN.md. * feat(scanners,docs): P3 audit remediation — wire dead Settings knobs + README truth Addresses the P3 tier of docs/reviews/full-audit-2026-07-05.md §10 (feature gaps that mislead users): - FEAT-6 (QUA-3): apply the stored Grype ignore config at scan time — a new grype_policy module materializes the YAML into tmpfs and the worker passes it to Grype via a `-c` config flag (mirroring the Trivy policy path). - FEAT-7 (QUA-3): the New Scan form prefills its severity filter and ignore-unfixed toggle from GET /settings/scanners so instance defaults take effect instead of being overridden by hardcoded form values. - FEAT-4 (QUA-3): the maintenance tick honors auto_update_db + interval, running `trivy image --download-db-only` and `grype db update` best-effort when due. - DOC-1/2/5 + FEAT-1/2/3/8: README aligned with reality — Docker Hub publishing is in scope; uploaded image-tar, Docker-env multi-select scan, and filesystem-archive upload are marked not-implemented; VEX/.trivyignore are global (not per-scan); the ECR/GCR/ACR helper-binaries caveat is stated. - FEAT-5/FEAT-10: offline DB import and admin bulk secret re-encryption are listed as not-yet-implemented; the key-rotation README claim is corrected. New tests cover the Grype config flag/materialization and the DB-update tick. Deviation logged in docs/PLAN.md. * fix(frontend): P4 audit remediation — session expiry, UTC times, restore label, RBAC gating Addresses the P4 tier of docs/reviews/full-audit-2026-07-05.md §10 (frontend correctness / UX): - FE-1: the API client emits an auth-invalidated event on any 401 and AuthContext drops to the login screen, instead of leaving a stale authenticated shell whose every action fails. - FE-3: a shared lib/dates.ts (parseUtc/formatWhen) renders backend naive-UTC timestamps; Account/Backups/Scheduled-scans stop showing UTC as local, and the ScanDetail/Scans private helpers are de-duplicated onto it. - FE-4: BackupsPanel's restore file uses useState (not useRef) so the selected file name re-renders on the destructive restore flow. - FE-5: ScheduledScansPanel constrains the scanner Select by target type (SCANNERS_FOR matrix + auto-correct) and gates Add/Run/Delete behind an operator/admin check; the /settings route is now guarded (viewers → /). Verified with tsc, ESLint, Prettier, and a clean vite build (no frontend test runner yet — FE-10 deferred to P5). Deviation logged in docs/PLAN.md. * fix(frontend): track src/lib/dates.ts (was hidden by the Python lib/ gitignore) The shared date helper added for FE-3 lives under frontend/src/lib/, which the generic Python-oriented `lib/` rule in .gitignore silently excluded — so the file was never committed and CI's fresh checkout failed the frontend build (and the image build) with "Cannot find module '../../lib/dates'". Add a .gitignore exception for the frontend source lib directory and commit the file. * fix(backup,tests): P5 audit remediation — envelope KDF params, migration drift test, deviation log Addresses the P5 tier of docs/reviews/full-audit-2026-07-05.md §10 (maintainability, process, long tail): - item (g): backup restore derives the passphrase key from the bundle's advertised scrypt params (kdf.n/r/p) instead of the module constants, so a bundle written under a different (e.g. older) work factor still restores. derive_key / passphrase_cipher take explicit, validated n/r/p; restore passes the recorded values. - QUA-23: new tests/test_migrations.py runs the real Alembic chain to head against a throwaway DB and asserts the tables/columns match Base.metadata (the rest of the suite builds via create_all). alembic/env.py now respects a caller-provided URL. Deviation-logging debt recorded in docs/PLAN.md (required regardless of fix): FE-2 (hand-rolled API client), INF-10 (HIGH/CRITICAL dogfood floor), API-12 (created_at vs started_at index), FEAT-4 (DB-schedule actuation). QUA-4/QUA-9, QUA-16, and FE-10 are explicitly deferred with rationale. * docs: defer INF-2 explicitly until the repo goes public Per user decision (2026-07-05): keep the merged-PR-only :dev publish trigger for now — while the repo is private, fork-based contributions can't happen, so the fork-secrets gap can't be triggered. Record in docs/PLAN.md that INF-2 must be revisited specifically before the repo is made public, since that is the event that enables fork PRs (and therefore the actual bug). INF-3's CLAUDE.md wording stays matched to the current trigger. * docs: add stacked-PR landing rules to CLAUDE.md (#30) Add guidance for landing multi-PR stacked batches: retarget each child PR's base to the true target branch immediately after its parent merges, re-state the full merge procedure before each merge rather than once per batch, and verify the target branch's actual content after the batch is reported complete instead of assuming merge order alone propagates changes through the stack. * ci: batch dev image to a nightly GHCR build; trim per-PR CI minutes (#31) * ci: batch dev image to a nightly GHCR build; trim per-PR CI minutes Restructure dev-image publishing and cut CI-minute usage. - Add .github/workflows/dev-nightly.yml: build the dev branch multi-arch once nightly (04:00 UTC) + manual dispatch and push the moving ghcr.io/iamgroot60/scrye:dev via the built-in GITHUB_TOKEN. Scheduled runs skip when dev has no new commits in 24h. - publish.yml is now release-only: drop the merged-PR :dev job and its pull_request trigger. Docker Hub (<dockerhub-user>/scrye) is referenced only by the release path. - Split registries by role: Docker Hub for tagged releases, GHCR for dev. - Resolve audit INF-2: a schedule trigger is not PR-triggered, so the fork-withheld-secrets gap no longer applies. - ci.yml minute reduction: run the two informational scanner reports on pushes to main only (dev PRs keep just the gate scans); add a cache-scope input to the build-image action and partition the GHA cache (amd64-ci vs multiarch vs dev-multiarch) so amd64-only and multi-arch builds stop evicting each other. - Update CLAUDE.md §6, docs/PLAN.md §0.6 + Deviations, README, and CONTRIBUTING to the two-registry model; remove the obsolete dev-publish smoke-test doc. * docs: prefer read-only default Actions permissions with per-workflow packages:write An explicit permissions: block overrides the repo-level "Workflow permissions" default, so GHCR push does not require raising the repo-wide default to read/write. Recommend keeping the restrictive read-only default and letting dev-nightly.yml declare its own contents:read + packages:write, matching the least-privilege posture. Update docs/PLAN.md and CONTRIBUTING.md accordingly. * perf(docker): speed up image builds via cache cross-seeding, cache mounts, parallel scanner downloads (#34) The image CI work was dominated by the multi-arch build check, whose arm64 leg runs the whole Dockerfile under QEMU emulation. CI logs showed it rebuilding cold every run (0 cached layers): the deliberately-partitioned `type=gha` cache scope it uses (`multiarch`) is only written on rare main/release events, so its entries age out between runs and the emulated arm64 layers get re-executed from scratch instead of restored. Apply the fixes consistently across all four build paths without weakening the supply-chain posture (scanner checksum verification, digest-pinned bases, and the non-root hardened final stage are unchanged): - Cross-seed the GHA cache scopes. Each build path still WRITES exactly one scope (keeping the 10 GB budget partitioning), but now also READS the frequently-warm sibling scope. The shared build-image action gains an `extra-cache-scopes` input (cache-from = primary + extras, cache-to = primary only). image-multiarch and the release build read the nightly's warm `dev-multiarch`; the nightly reads `multiarch`; the amd64 dogfood build reads `dev-multiarch` for warm amd64 layers. - Persist pip/npm download caches with BuildKit cache mounts so an unchanged dependency isn't re-fetched when its install layer rebuilds; drop PIP_NO_CACHE_DIR (the cache lives in the mount, not the image layer). - Parallelize the trivy/grype/syft download+verify+extract pipelines (each in a background subshell joined by `wait`); a checksum mismatch in any still fails the build via `wait` under `set -e`. Documented in docs/PLAN.md § Build performance (with the do-not-undo invariants and a per-path before/after) and a guardrail rule in CLAUDE.md. * docs,ci: add post-promotion back-merge step; retarget Dependabot to dev (#35) The dev/main release model (squash-merged promotion PRs, plus Dependabot targeting the default branch main) leaves dev showing commits 'behind' main after each release. Two coupled changes stop that recurring: - .github/dependabot.yml: set target-branch to dev so github-actions bumps open against the integration branch instead of landing on main and never reaching dev. Removes the avoidable drift source. - CLAUDE.md and CONTRIBUTING.md: document a back-merge step — after each dev->main promotion (and after any commit that lands on main directly), merge main into dev, resolving squash-divergence conflicts in favour of dev. Handles the unavoidable promotion-squash case. See docs/PLAN.md § Deviations (2026-07-07) for the rationale and the one-time reconciliation performed alongside this change.
…#38) * docs: overhaul README/CONTRIBUTING; split PLAN into ARCHIVE + ROADMAP Rewrite the documentation to match the current codebase and separate the historical build record from forward-looking planning. - README.md: rewritten from the verified codebase. Deeper Docker deployment section (prerequisites, master-key generation, Docker Hub vs GHCR vs local image, compose invocation, first-run admin bootstrap, persistent-data layout, optional sidecars, reverse-proxy setup, first-run troubleshooting incl. the read-only-root /cache cache-path class of issue). Completed the env-var table (adds SCRYE_FORWARDED_ALLOW_IPS, SCRYE_SCANNER_MAX_OUTPUT_BYTES, SCRYE_SCANNER_CACHE_DIR). Added a UI-based "Configuring OIDC" section. Security model, backup/restore, and monitoring refreshed. Roadmap now links to docs/ROADMAP.md (forward) and docs/ARCHIVE.md (history). - CONTRIBUTING.md: merged the two duplicate "Releasing" sections into one with subsections; updated the docs/ tree and deviation-log pointer to ARCHIVE.md/ROADMAP.md; dropped the now-historical phase/PX branch guidance. - docs/PLAN.md -> docs/ARCHIVE.md: preserved verbatim as the historical build record (phase order, locked decisions, deviations log, build-performance notes); header updated to describe its archival role. - docs/ROADMAP.md: new forward-looking roadmap (near/medium/longer-term) plus known limitations and accepted trade-offs (SBOM content-identity, OIDC/MFA policy scope, key-rotation re-encryption tool, offline DB import, etc.). - CLAUDE.md: repointed docs/PLAN.md references to docs/ARCHIVE.md and noted docs/ROADMAP.md for forward-looking work. * docs,ci: go public and consolidate publishing to GHCR-only Scrye's repository is going public; drop Docker Hub entirely and publish everything to GHCR, and update the docs for a public audience. Publishing (GHCR-only): - publish.yml now builds release tags to ghcr.io/iamgroot60/scrye:<version> and :latest, authenticating with the built-in GITHUB_TOKEN (packages:write) instead of the DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets. Adds a canonical-repo guard so a fork that pushes a tag skips instead of failing. - The nightly :dev build (dev-nightly.yml) was already GHCR/GITHUB_TOKEN; only its comments changed. ci.yml, the composite build action, and dependabot.yml had their Docker-Hub-era comments corrected. - INF-2 (the fork-PR :dev secrets gap, previously deferred because the repo was private) is now fully closed: both publish paths are triggered outside pull_request and use GITHUB_TOKEN, so no pull_request-triggered workflow carries a registry secret. The DOCKERHUB_* repo secrets are now unused. Public-repo governance: - Add .github/CODEOWNERS (owner review) and SECURITY.md (private vulnerability reporting, supported-tags table, scope). - Branch protection and signed-commit enforcement are repository settings, not files; tracked as a checklist in docs/ROADMAP.md. Docs: - README: GHCR-only distribution; a complete standalone pull-from-GHCR docker-compose.yml so deploying needs no clone (cloning is now a separate "build from source" path); env vars categorized by necessity (required/conditional/optional); real nginx/Caddy/Traefik reverse-proxy examples; clearer optional-sidecar necessity; GHCR + CI badges; dropped the build-history pointer from the Roadmap section. - CONTRIBUTING § Releasing rewritten for GHCR; ROADMAP reflects the public repo (free arm64 runners, governance checklist); CLAUDE.md locked decision §6 rewritten GHCR-only. Dated deviation entry added to docs/ARCHIVE.md.
…39) Three frontend/backend fixes: - Theme: replace Mantine's minty default teal with the Tailwind teal ramp; primary is teal-700 (#0f766e) in light mode and teal-600 (#0d9488) in dark mode, with autoContrast + luminanceThreshold 0.2 so filled controls stay legible. All primary usages clear WCAG AA (light 5.47:1, dark filled 5.61:1, dark text 4.60:1). Applies everywhere color="teal" resolves via primaryShade (wordmark, nav, buttons, badges, loaders, pagination). - Scans: add DELETE /api/scans/{id} (operator role + CSRF, terminal-status only). Removing the scan cascades to its findings, artifact-metadata rows, and tags via the existing ORM/FK cascade; the on-disk artifact directory is removed via a new remove_scan_artifacts() helper. No schema change, so no migration. A confirmation modal + Delete button is added to the scan detail page. Deleted scans stop feeding the dashboard aggregates and drop out of history/diffs. Backend tests cover full cleanup, RBAC, CSRF, and queued/404. - Nav: fix the active-link matching that lit both "Scans" and "New scan" on /scans/new. The active item is now the longest matching nav path (exact or "to/" prefix), so each item highlights only for its own route. See docs/ARCHIVE.md § Deviations for details.
Update all references to the old GitHub username across the repo after the account rename: - GitHub URLs (github.com/IamGroot60/Scrye → github.com/tyler-rich/Scrye) in README, SECURITY.md, and docs. - GHCR image paths (ghcr.io/iamgroot60/scrye → ghcr.io/tyler-rich/scrye) in README, CONTRIBUTING, CLAUDE.md, docs/ARCHIVE.md, and both publish/nightly workflows. - README badges (CI + GHCR container); escape the dash in the shields.io GHCR badge (tyler--rich) so it renders literally. - CODEOWNERS catch-all owner and LICENSE copyright holder. - Git-identity rule in CLAUDE.md, including the noreply email ([email protected]; numeric ID unchanged). - Workflow repo-owner fork guards: github.repository == 'tyler-rich/Scrye' in both dev-nightly.yml (nightly build) and publish.yml (release build). GHCR auth is unchanged: both workflows use github.actor + GITHUB_TOKEN, which are repo-scoped and carry no hardcoded username.
…le) (#43) Docker Scout scan of the published ghcr.io/tyler-rich/scrye:0.1.0 image surfaced ten CVEs, all inside the bundled upstream scanner binaries' embedded Go stdlib / oras-go / moby / sigstore-timestamp-authority modules — none in Scrye's own attack surface. Version-bump-if-available check outcome: Trivy 0.72.0, Grype 0.115.0, and Syft 1.46.0 are each already the latest available upstream release, so no bump resolves any of the findings yet. Left the Dockerfile pins unchanged and logged the CVE -> upstream-fix-version mapping as a tracked limitation in docs/ARCHIVE.md § Deviations, per the existing bundled-binary CVE-tracking pattern. No binary changed, so the image is unaffected and the app version stays 0.1.0 (no 0.1.1 cut).
* Add files via upload * docs: wire up README screenshots Move uploaded screenshots into docs/screenshots/ and replace the README placeholder table with the real dashboard, new-scan, results, and history captures. Drop the now-stale roadmap item.
Cap each screenshot to a fixed width via HTML img tags so the table renders uniformly regardless of each capture's native dimensions.
Crop each screenshot to its actual content, trimming the large blank page background that made captures like New scan and History look tiny next to Results. Render all four at a fixed 260x200 box so the table is visually consistent.
GitHub's markdown CSS forces height:auto on images, so the explicit height attribute from the previous fix had no effect and thumbnails still rendered at different sizes. Pad each screenshot with matching background color to a common canvas height instead, so a single width attribute renders all four uniformly.
The previous fix cropped each screenshot to its content, then padded back up to the tallest capture's height (1241px) — which happened to equal the original canvas size, silently undoing the crop and leaving the thumbnails just as mismatched as before. Crop all four to the same 900px window from the original captures instead, so they share actual pixel dimensions with no padding involved.
…thon 3.13 interpreter CVEs (#51) Pin curl/libcurl3-gnutls/libcurl4 to 7.88.1-10+deb12u15 (the Debian bookworm-security fix for CVE-2026-5773) in the runtime stage; the fix is already in the base image's apt snapshot, so no base-image digest bump was needed. Waive the four CPython interpreter-binary CVEs on Python 3.13.14 (CVE-2026-15308, CVE-2026-12003, CVE-2025-15366, CVE-2025-15367) in ci/grype.yaml with per-group review dates. Decision: stay on Python 3.13 for now; the 3.14 move is deferred to a scoped project (docs/upgrades/python-3.14.md). Full rationale in docs/ARCHIVE.md §14. Tracking: #52
…mmary (#50) * docs: gather six /code-review reports onto feat/reviews Consolidates reports from api-db-models-review, claude-md-compliance-audit, mantine-frontend-review, scrye-concurrency-review, scrye-security-review, and supply-chain-security-review branches. * docs: add consolidated severity-ranked summary of the six review reports
A `target_type=repository` scan was validated only for length and a leading `-`, then passed straight to `trivy repo -- <target>`. Trivy's `repo` subcommand accepts a local filesystem path, so an operator could submit a target like `/data` or `/run/secrets` and have Trivy walk the container filesystem, persisting the results as a downloadable artifact — bypassing the `SCRYE_FILESYSTEM_SCAN_ROOTS` allowlist that exists precisely to keep the SQLite DB and master-key file unreadable as scan output (SEC-1 / Top 5 #1). Restore the invariant that a repository target must be a remote git clone URL: add `is_remote_repo_url` (reusing `is_http_url` plus an ssh/git scheme allowance) and enforce it with a `ScanCreateIn` model validator, which rejects local paths at request time (422). Because `ScanScheduleIn` subclasses `ScanCreateIn`, the guard covers scheduled scans too. Add regression tests: `/data`, `/run/secrets`, `/`, `/app`, and `file://` targets are rejected, a valid remote clone URL still runs, and direct coverage of the `is_remote_repo_url` helper.
… make restore guard atomic, clamp bundle scrypt params (#54) Remediates the compounding CON-1/CON-11/CON-3/SEC-2 findings from the 2026-07-12 review batch (docs/reviews/00-summary.md Top 5 #2): - Worker DB commits (claim, persist, fail, raw-output artifact) now retry SQLite lock-contention OperationalErrors with bounded exponential backoff, and a successful scan's artifact files are only unlinked after the final attempt fails - transient contention no longer loses results or strands scans running forever (CON-1). - A stale-scan watchdog in the maintenance tick re-submits queued scans with no live task and fails task-less running scans via an atomic conditional update, so shutdown races and broken commit chains self-heal within a tick instead of at the next restart (CON-11). - restore_bundle takes BEGIN IMMEDIATE and re-checks the active-scan guard inside the write transaction (RestoreConflictError -> 409), closing the check-then-act window across the upload await; the restore endpoint pauses the worker for the duration (CON-3). - Restore-supplied scrypt cost parameters are clamped and maxmem is a fixed budget rather than derived from the bundle's own untrusted n/r, so a crafted bundle can no longer OOM the container pre-passphrase (SEC-2). The ScanWorker seam gains optional default-no-op reconcile_stale/pause/resume hooks; the core submit/recover/shutdown interface is unchanged. See docs/ARCHIVE.md § Deviations (2026-07-13) for the full record.
…aseline (#55) Close the audit's account-takeover chain (FE-9 + missing response-header baseline) with two coupled changes. Frontend: - Add safeHttpUrl() (frontend/src/lib/url.ts): admit only well-formed http(s) URLs, rejecting javascript:/data:/vbscript:/relative/malformed. - ScanDetailPage renders a finding's scanner-derived primary_url as an Anchor only when it passes safeHttpUrl (inert text otherwise) and adds rel="noopener noreferrer". This was the only place scanner-derived data was rendered as a link. - Add vitest as the frontend unit-test runner (npm test) with a Node-environment test config; unit-test safeHttpUrl; wire npm test into CI. Backend: - Add SecurityHeadersMiddleware: every response gets X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, and a Mantine-SPA-tuned Content-Security-Policy (script-src 'self'; style-src 'self' 'unsafe-inline' for Mantine's runtime style injection; connect-src 'self'; object-src/frame-ancestors locked down). The /docs and /redoc UIs are exempt from the CSP only (they need inline scripts + CDN assets). The CSRF cookie's double-submit design is unchanged. - Test asserts the headers on responses and the docs-CSP exemption. Verified the built SPA renders under the new CSP with zero policy violations. Docs: README security model, CONTRIBUTING testing, ARCHIVE deviation entry.
…/CON-14) (#56) proc.kill() only signals the direct child; git clone spawns git-remote-https and scanner binaries can spawn their own helpers, so on timeout, output-cap overflow, or shutdown cancellation those grandchildren kept running with SCRYE_GIT_PASSWORD/GIT_ASKPASS still in their environment. run_command now starts children with start_new_session=True and kills the whole process group via a new _kill_process_group helper on all three abort paths, suppressing ProcessLookupError so an already-exited group can't replace the abort's own error (CON-14). See docs/ARCHIVE.md § Deviations for details.
…kouts (#57) Resolve SC-2 (HIGH): pin every `uses:` in .github/workflows/*.yml and the composite build-image action to a full commit SHA with the version kept as a trailing comment, so a compromised action can no longer move a tag under publish.yml/dev-nightly.yml's packages:write token. While pinning, also align the composite action's action majors with ci.yml (setup-qemu-action and setup-buildx-action v3 -> v4.2.0, build-push-action v6 -> v7.3.0) so build behavior and audit surface match across all three workflows (L25/D3). Resolve SC-3 (HIGH): extend dependabot.yml beyond github-actions with pip (backend), npm (frontend), docker and docker-compose (base/sidecar images) ecosystems, all targeting dev on a weekly grouped schedule. Also split the composite action into its own github-actions directory entry, since Dependabot's github-actions ecosystem does not recurse into composite actions outside .github/workflows/ (D3). Resolve L24/SC-11 (LOW): set persist-credentials: false on the checkout steps in publish.yml and dev-nightly.yml, the two token-bearing (packages: write) workflows, since no later step in either needs a credentialed git remote.
Dependabot's grouped pip bump (PR #58) raised pytest to 9.0.3 but left pytest-asyncio at 0.25.1, which pins pytest<9, producing an unsatisfiable resolution. Bump pytest-asyncio to 1.4.0 (allows pytest<10) alongside the pytest and black bumps so the dev toolchain resolves. Verified on Python 3.13: full suite 476 passed / 3 skipped, black --check clean, ruff clean.
…ol findings (#60) * fix(worker,api): offload synchronous DB work off the event loop (CON-5) The maintenance tick's scanner-DB policy read, the worker's per-scan Trivy/Grype policy loads, and the scan-queue insert/audit/commit ran synchronously on the event loop. A concurrent long writer holding the SQLite write lock would stall the whole loop inside busy_timeout (up to 5s), freezing every request, healthcheck, and subprocess pump. Hop each off the loop via a worker thread, matching the pattern already used for result persistence and restore. * fix(worker,compose): fit graceful shutdown within the container stop budget (CON-6) The worker's 10s drain grace alone equalled Docker's default 10s SIGTERM->SIGKILL budget, so a busy instance was SIGKILLed mid-commit before the cancel/gather and scheduler shutdowns could run. Add an explicit stop_grace_period (30s) to the Compose file, shrink the worker drain grace to 5s, and bound each scheduler's shutdown wait with asyncio.wait so a task wedged in a non-cancellable threaded pass is abandoned after a timeout instead of blocking the stop path. * fix(app): shield and fault-isolate the lifespan shutdown sequence (CON-7) The lifespan finally ran maintenance/backup/worker shutdown as three sequential, unshielded awaits: a second cancellation (uvicorn forced exit / second SIGINT) mid-sequence aborted it before worker.shutdown(), so live scanner subprocesses were never cancelled and ran until SIGKILL; an exception from one shutdown likewise skipped the rest. Wrap the teardown in asyncio.shield and run each component's shutdown under its own try/except so the worker is always stopped last. * fix(auth): make PendingMfaStore thread-safe (CON-8) The store is shared across the sync login/verify_mfa endpoints, which run on different threadpool threads, but _prune iterated _pending with no lock — a concurrent issue() inserting mid-iteration raises 'dictionary changed size during iteration' and 500s a valid login. Guard issue/consume/prune with a threading.Lock, mirroring the rate limiter. * fix(backup): consistent-snapshot dump + defer scheduled backup while scans run (CON-9) build_bundle read each table in its own implicit transaction, so a scan committing between the scans and findings reads produced a torn bundle. Wrap the whole dump in one explicit BEGIN so it reads a single WAL snapshot. Additionally, run_due_backup now skips (and logs) while any scan is queued or running, mirroring the manual restore guard, so an in-flight scan can't be captured mid-running and restored as stuck; last_run_at is left unset so the backup retries once the scan finishes. * fix(worker,db): stop pinning a pooled connection across the scan subprocess (CON-10) A running scan held its pooled DB connection for its full wall-clock, so concurrency was silently capped by the pool (5+10) and every API call 500'd past that. The worker now resolves all DB inputs (policy + credentials/paths, with decrypts) up front into detached values, rolls back to return the connection to the pool, runs the minutes-long scanner subprocess holding none, and re-acquires only for persistence. As defense-in-depth the pool is sized from max_concurrent_scans (+headroom) and the setting is capped at 32. Regenerated .env.example. * fix(worker): advance DB-update marker only on success (CON-12) maybe_update_scanner_dbs set its last-run marker before running the two updates, so a transient failure (e.g. a registry outage) silently left the vulnerability DBs stale for a full db_update_interval_hours while the UI implied freshness. _run_update now returns success and the marker is advanced only when at least one engine actually updated; a total failure leaves it unset to retry on the next tick. * fix(worker): run scanner-DB refresh off the maintenance tick's critical path (CON-13) The tick awaited maybe_update_scanner_dbs (two subprocesses, each capped at 600s) inline, so a slow mirror could delay the next tick's due schedules and retention by up to ~20 min. The refresh now runs as its own detached task, guarded so at most one runs at a time; the tick returns promptly after schedules + retention. * fix(worker): dispatch scan notifications after releasing the semaphore slot (CON-15) Notification dispatch ran inside the concurrency-semaphore block, so a scan finishing against slow or dead channels (10-15s transport timeouts each, sent sequentially) kept holding its slot while queued scans waited. _run now returns whether a terminal state was reached and _execute dispatches the notification only after the async-with semaphore block exits, freeing the slot first. * fix(worker): retrieve task exceptions and bound task spawning (CON-16) The per-scan task's done callback only discarded the task, so an error escaping _execute (e.g. the session factory failing in a shutdown race) died as a silent GC-time 'Task exception was never retrieved'. The callback now retrieves and logs it; the session-factory call is guarded so such a failure leaves the scan QUEUED for the watchdog. submit() also caps concurrently-live tasks (generously, scaled from max_concurrent) so a submission flood is deferred to the watchdog instead of piling up unbounded pending tasks. * fix(schedules): stamp last_run_at on 'Run now' to stop same-minute tick duplicate (CON-17) run_schedule_now created a scan without touching last_run_at, so the cron tick (which fires on last_run_at) could fire the same schedule again in the same minute — a duplicate back-to-back scan and a raced last_scan_id. 'Run now' now records the run (last_run_at + last_status), so the tick finds the schedule not due for the rest of the minute. * fix(dashboard): don't abandon the scanner-DB probe on a DB error (CON-18) The dashboard gathered DB aggregation and the subprocess-backed scanner-DB freshness probe without return_exceptions, so a DB failure propagated immediately and left the probe's subprocesses running detached (repeated reloads under contention accumulated duplicate probes). gather now uses return_exceptions=True and handles each branch: a DB error re-raises after the probe is awaited to completion; a probe error degrades to an empty scanner-DB list instead of failing the whole dashboard. * fix(worker): re-read the scan before notifying to catch a concurrent delete (CON-19) _notify's session.get was served from the identity map (expire_on_commit= False keeps the just-committed scan cached), so a scan deleted the instant after it reached a terminal state was still announced — e.g. a webhook whose link 404s. Passing populate_existing=True forces a fresh read, so a concurrent DELETE is seen and the notification is skipped. * fix(scanners): remove the repo checkout off the event loop (CON-20) generic_repo_checkout's finally ran shutil.rmtree on the clone directory inline; a multi-GB working tree blocked the event loop for seconds while walking and unlinking it. Hop the checkout removal to a thread, shielded so the cleanup still completes when the finally runs under cancellation (worker shutdown). The small tmpfs cred dir removal stays inline. * test(worker): make the CON-16 crashed-task log assertion config-independent Spy on the module logger instead of caplog so the assertion doesn't depend on logging propagation set up by other tests in a full run. * docs: log CON-5–CON-20 concurrency-review remediation in ARCHIVE deviations
* fix(trivy-policy): normalize ignore-rule expires_at to naive UTC (APIR-1) A timezone-aware expires_at (e.g. 2026-08-01T00:00:00+09:00) was persisted into the naive-UTC DateTime column with its offset silently dropped, so a Trivy ignore rule kept suppressing a CVE for the offset's worth of extra time. Add a shared to_naive_utc helper in core.timeutil (the single source of truth scan_filters now reuses) and apply it as a field_validator on IgnoreRuleIn.expires_at, covering both the create and replace paths. * fix(api): flatten schema-validation 422s to a string detail envelope (APIR-2) FastAPI's RequestValidationError put a list in detail while hand-raised HTTPException(422) put a string there; the SPA only renders the string shape, so every schema-level validation failure (too-long tag, bad cron, target starting with -) showed as a blank "Request failed (422)". Add a RequestValidationError handler that flattens the first error into the same string detail envelope every other 422 already uses, prefixed with the offending field. * fix(api): serialize response timestamps with an explicit UTC Z (APIR-5) Naive-UTC timestamps were serialized as bare ISO-8601 with no zone designator, so a consumer parsing them as browser-local shifted every instant by its UTC offset; ScansPage.runCompare did exactly this and could invert a diff's base/compare ordering across a DST boundary. Add a shared UtcDatetime field type (PlainSerializer appending Z in JSON mode only) and apply it to every response-model datetime; storage stays naive. Route ScansPage's compare ordering through parseUtc as belt-and-suspenders. * fix(scan-diff): gate Compare on target_type and add location to diff payload (APIR-3) The SPA enabled Compare for two scans sharing scanner+target string but differing in target_type, which the diff endpoint then rejects with 422 — a dead end. Add the target_type equality to canCompare. Separately, the diff identity keeps location for every non-vulnerability class (one rule fires across many files), but DiffFindingOut dropped it, so distinct per-file occurrences serialized as byte-identical rows; add location to the payload, the DiffFinding type, and a Location column on the diff view. * fix(history-export): signal truncation when the 5000-scan cap fires (APIR-4) A filtered-history export capped at _MAX_HISTORY_EXPORT_SCANS gave no indication the cap fired, so a consumer read a partial download as the complete filtered set. Count the full matching set in export_history_view and thread it through export_history: JSON gains total/truncated metadata, Markdown a visible note line, CSV a leading '#' comment, and every format an X-Scrye-Truncated / X-Scrye-Total response header. Signals are only emitted when truncation actually happened. * fix(api): align secret-bearing update validation with create (APIR-6) Update paths could reach states create forbids. PATCH secret="" on a notification channel cleared a mandatory secret, leaving an enabled channel that only fails at send time — now rejected 422 unless the type is in SECRET_OPTIONAL_TYPES. RegistryUpdateIn didn't strip name/registry_host, so ' ghcr ' could shadow 'ghcr' past the 409 duplicate check — add the same strip validator create uses; and blanking the username on a username_password registry is now rejected. * test(schedules): assert run-now stamps last_status (APIR-7) APIR-7 (run-now leaving last_run_at/last_status stale) was already fixed by the CON-17 remediation in #60, which stamps all three last_* fields in run_schedule_now. Add the last_status assertion the review specifically called out so the resolved behavior stays locked in; no code change needed. * refactor(audit): rename pagination envelope entries -> items (APIR-8) The audit list was the only paginated endpoint using a third key name (entries) instead of the {total, items} envelope shared by history and findings. Rename it for consistency; the endpoint is admin-only with no frontend consumer, so the blast radius is the backend tests. Broader standardization of the unpaginated bare-array admin lists is intentionally out of scope per maintainer direction. * refactor(scans): trim list/history/dashboard rows to a summary shape (APIR-9) List, history, and dashboard-recent rows shipped the full ScanOut on every row — including options (which also leaks internal registry_id/ git_credential_id to viewers) and unbounded scanner error text — none of which those views render. Split a ScanSummaryOut (drops options/error, adds a has_error flag) used by those endpoints; the full ScanOut, extending the summary with options + error, is returned only by the single-scan detail endpoint where the SPA actually reads them. Frontend mirrors the split with a ScanSummary type. * refactor(scanners): extract the scanner-target matrix to one source (APIR-10) The scanner-to-target-type compatibility matrix was duplicated in the scans and scan-schedules routers and had already drifted (the schedules copy omitted the SBOM row). Move it to app/scanners/support.py as SCANNER_TARGET_SUPPORT + scanner_supports(); both routers import it, so adding a combination is a one-line change neither router can miss. The schedules router keeps only its extra 'SBOM cannot be scheduled' rule. * docs(archive): log the APIR-1..APIR-10 API-review batch
* fix(frontend): gate settings forms until initial GET resolves (M19)
Settings forms rendered editable with hardcoded defaults and enabled
Save from first paint, so on a slow connection an admin could Save before
the initial GET resolved — silently writing the built-in defaults over
the live policy — or have a late response overwrite in-progress edits.
- RetentionPanel/GeneralPanel: track a `loaded` flag; disable the inputs
and show a loading Save button until the GET resolves, and only seed
fetched values into a form the admin hasn't already started editing
(`!form.isDirty()`).
- BackupsPanel: split the schedule-form hydration out of the shared
load() so list mutations (create/delete/restore) no longer re-hydrate
and reset unsaved schedule edits; gate the schedule controls on load.
- NewScanPage: apply the FEAT-7 instance-default prefill only to a
pristine form so a slow response can't revert a user's severity/
ignore-unfixed edits.
* fix(frontend): back off and halt the scan-detail poller on errors (M20)
The 2.5s status poll was gated only on isActive(scan.status), and a
failed loadScan() kept the previous (still-active) scan in state — so the
interval fired forever against a failing endpoint while the badge kept
claiming "running". A restarted backend, expired session, or a scan
deleted by another admin would be hammered every 2.5s indefinitely.
The poller now uses a self-scheduling timeout with exponential backoff
(2.5s → 30s cap) and halts after MAX_POLL_FAILURES consecutive failures,
surfacing an "Auto-refresh paused" alert with a Retry affordance; a 404
is treated as terminal (scan gone). Backoff math lives in a pure
lib/polling helper with unit tests.
* fix(frontend): guard history fetch against stale responses (M21)
The 250ms debounce delayed sending history requests but never cancelled
in-flight ones, and load() had no latest-wins check before setData(). A
slow request for one filter could resolve after a newer request and
overwrite the table and pagination with rows for a filter no longer
selected. A per-view latest-wins guard (new lib/latest, unit-tested) now
tags each request and drops out-of-order resolutions.
* fix(frontend): stop the status poll from wiping tag edits (L16)
loadScan() unconditionally reset the tag draft to the server's list, and
the 2.5s status poll calls it while a scan is active — so an operator
typing into the TagsInput on a running scan had their half-entered draft
reset on every tick. Track the last server tags synced into the draft and
re-adopt the server value only while the draft still matches it (ordered
equality via a new lib/arrays helper, unit-tested), preserving any
in-progress edit.
* fix(frontend): reset per-scan state on scan-detail navigation (L17)
React Router reuses the ScanDetailPage instance when only :scanId
changes, and no state was reset — so navigating between scans left the
previous scan's header, findings, artifacts, and tag draft on screen
until the new fetch landed, and the artifacts/findings effects (gated on
the still-stale scan.status) fired for the new id against the old status.
Reset scan/findings/artifacts/filters/tag draft and poll state in an
effect keyed on the id.
* fix(frontend): add findings loading state and stale-response guard (L18)
The findings panel initialized to [] with no loading flag, so a succeeded
scan flashed "No findings match the current filters." until the first
fetch resolved — false for a scan with thousands of findings — and rapid
filter toggles rendered the previous filter's rows with no indication a
load was pending. Track loading/loaded flags (show a Loader on first load
instead of the empty state; dim the table with a LoadingOverlay during
filter changes) and reuse the latest-wins guard so only the final
response renders.
* fix(frontend): guard double-fire on in-flight mutations (L19)
Several mutation triggers had no in-flight disable, so a double-click
fired them twice. Most seriously, double-clicking Create in ApiTokensPanel
minted two tokens while the plaintext alert showed only the second —
leaving an active bearer token the user never saw and can't copy. Add
in-flight guards (early-return + loading/disabled buttons) to:
- ApiTokensPanel create + revoke, UsersPanel create
- ScheduledScansPanel create, per-row Run now / Delete
- AccountPage MFA enroll/confirm/disable (a second activateMfa is
rejected and its error clobbers the success status), session revoke,
and change password.
* fix(frontend): make the history table keyboard-accessible (L20)
The sortable headers were Table.Th with onClick and a pointer cursor —
no focusable element, key handler, or aria-sort — so keyboard users
couldn't sort and screen-reader users were never told the sort
column/direction. Row navigation was onClick on two Table.Td cells, so
the only focusable control in a row was the compare checkbox and keyboard
users couldn't open a scan at all. Wrap the header trigger in an
UnstyledButton with aria-sort on the cell, and make the scanner/target
cells Anchor component={Link} to /scans/:id (matching the dashboard).
* fix(frontend): add accessible names to unlabeled controls (L21)
Several controls were placeholder-only or labeled only by adjacent text
with no programmatic association, leaving them with no accessible name:
the scan-detail severity/class filter Selects and tags TagsInput, the
New scan Target type / Scanner SegmentedControls (two anonymous radio
groups), and the MFA-enrollment PinInput. Add aria-label to each,
matching the LoginPage PinInput's existing "Authentication code".
* fix(frontend): add a mobile navigation fallback below sm (L22)
NavLinks was wrapped in Group visibleFrom="sm" with no alternative, so
below the sm breakpoint — on a phone, or at 400% zoom (WCAG 1.4.10
reflow) — the app had no navigation at all and pages were reachable only
by editing the URL. Add a Burger (hiddenFrom="sm") that opens a Drawer
with a vertical variant of the same nav links, closing on selection.
* docs: log frontend-review wave 2 remediation in ARCHIVE deviations
Record the M19–M21 / L16–L22 frontend fixes in docs/ARCHIVE.md §14, per
the CLAUDE.md convention for review-remediation batches.
…4, L23) (#64) * build(deps): add hash-pinned backend lockfile and install it in the image (SC-1) pyproject.toml pins only direct runtime dependencies, so every image build previously resolved the transitive closure (anyio, certifi, h11, cffi, Mako, etc.) fresh from PyPI, unpinned and unverified — two builds of one commit could differ, and a newly compromised transitive release would be picked up silently. Add backend/requirements.lock: the fully-resolved, hash-pinned transitive set compiled from pyproject.toml with `uv pip compile --generate-hashes` (uv is a build/dev-time tool only; it is not added to the runtime image and pyproject stays standard PEP 621). The image now installs runtime deps with `pip install --require-hashes -r requirements.lock` before copying app code (better layer caching), then the app package itself with `pip install --no-deps .` so the resolver can't reach back to PyPI for an unpinned copy. CI regenerates the lock with the pinned uv and the identical compile flags and fails on drift; a Dockerfile guard test pins the --require-hashes install so a future edit can't silently drop hash verification. CONTRIBUTING documents the regeneration workflow. Verified: the lock installs and hash-verifies all runtime deps under Python 3.13, and the app package builds and imports with --no-deps. * fix(crypto): enforce a high-entropy master key (SEC-3) The only strength gate on the master key was length: a memorable passphrase was accepted as long as it was >= 32 bytes, and the field key is then derived with a single fast HKDF expand. If an attacker obtained scrye.db (the exact DB-read threat the field encryption defends against) and the operator had chosen a low-entropy but long key, it could be brute-forced offline at HKDF speed. Add an entropy floor at key load: the master key material must be valid base64 decoding to >= 32 bytes — the documented `openssl rand -base64 48` form. Raw passphrase material (anything not valid base64) is rejected with an actionable error instead of silently accepted via the UTF-8 fallback. A temporary boot-and-rotate escape hatch, SCRYE_ALLOW_WEAK_MASTER_KEY, lets an existing passphrase-keyed deployment start long enough to rotate; it logs a warning on every load so it can't quietly become permanent. This is input validation only. Key derivation and the on-disk token format are unchanged, so every existing encrypted value still decrypts (regression test added). README security model documents the requirement and the escape hatch. * fix(egress): screen SSRF targets in server-side fetchers (SEC-6) The notification transports (webhook/Discord/Matrix/SMTP), the registry connectivity probe, and the Docker socket-proxy client all took an admin-supplied URL/host and connected with no destination check, so they could be pointed at the cloud metadata endpoint (169.254.169.254), loopback, or an internal service — an SSRF surface, admin-gated but unguarded. Add core/egress.py: resolve the target host and refuse loopback, link-local (including cloud metadata), multicast, unspecified, and reserved addresses always, and RFC-1918/ULA/CGNAT private addresses unless the operator opts in with SCRYE_ALLOW_INTERNAL_EGRESS (self-hosted deployments legitimately run an internal SMTP relay or private registry). The Docker proxy targets an internal sidecar by design, so it allows private addresses but still refuses loopback/metadata. The registry bearer-token realm — attacker-influenced, from the probed registry's own header — is screened too. Resolution here is best-effort (httpx re-resolves on connect), which is appropriate for an admin-gated, CSRF-protected surface. Settings gains allow_internal_egress (off by default); README security model and the env-var table document it. * fix(logging): redact whole unquoted secret values, not just the first token (SEC-4) The key/value redaction filter's unquoted-value branch stopped at the first whitespace, comma, semicolon, or ampersand, so an unquoted secret containing any of those — an SMTP password, a backup passphrase, a multi-word token — was only prefix-masked and its tail leaked to the log (`password=p@ss w0rd here` left `w0rd here`; `api_key=abc,def` left `,def`). Make the unquoted match tempered-greedy: consume to end of line unless it first reaches the start of another `key=`/`key:` pair, so a spaced/comma-bearing secret is redacted whole while a following structured field (`region=us`) is still preserved. The value is anchored to a non-space first character so the engine can't backtrack the key's trailing space and start the value on it, slipping past the Bearer/Basic guard. Redaction stays bounded to a single log line. Over-redacting a trailing free-text phrase is the accepted trade-off for never leaking secret bytes; two tests that asserted trailing context survived a single-token secret are updated, and a spaced-secret regression test is added. * build(docker): refresh the stale node:22-bookworm-slim build-stage digest (SC-6) The frontend builder pinned a node:22-bookworm-slim digest that upstream had superseded by ~a Debian patch cycle, so the build ran on an older OS snapshot. Bump to the current manifest-list digest (sha256:53ada149d435c38b14476cb57e4a7da73c15595aba79bd6971b547ceb6d018bf). Tag + digest are kept together (human-readable and immutable). Builder stage only — nothing from it ships to the runtime image except the compiled dist/. * build(compose): bump docker-socket-proxy 0.3.0 -> v0.4.2 (SC-7) The read-only Docker socket proxy — the single most security-sensitive sidecar, the only container mounting docker.sock — was pinned to 0.3.0 (~15 months old), running a year-plus-old HAProxy base. Bump to v0.4.2 (current stable; upstream now prefixes tags with `v`) with its resolved manifest digest. The env-var interface (IMAGES/CONTAINERS/INFO/POST) is unchanged across the 0.3 -> 0.4 line and the app only calls GET /images/json (gated by IMAGES=1), so no client change is needed. The existing hardened-boot NOTE (read-only FS + /run tmpfs + cap_drop:ALL; add SETUID/SETGID only if privilege-drop fails) still applies and should be re-verified on a Docker-Hub-reachable host — image pull/boot could not be exercised in the egress-restricted environment where this was prepared. A future migration to wollomatic/socket-proxy is tracked separately (#63). * ci: attest build provenance + SBOM on published images (SC-4) Published images carried no provenance or SBOM attestation, so a consumer of ghcr.io/tyler-rich/scrye couldn't verify that :latest/:<version>/:dev was built by this repo's workflow from a given commit — a credibility gap for a tool whose product is SBOM/vulnerability transparency. The shared build-image action gains provenance/sbom inputs (default off, so the CI build-only check is unaffected) and exposes the pushed image digest. The two publish workflows turn on BuildKit SLSA provenance (mode=max) + SPDX SBOM on the image manifest, and add a GitHub-signed build-provenance attestation via actions/attest-build-provenance (SHA-pinned), verifiable with `gh attestation verify oci://ghcr.io/tyler-rich/scrye:<tag>`. The id-token/ attestations scopes are granted at job level only, on the publish workflows. CONTRIBUTING documents verification. * ci: scheduled re-scan of published images for new CVEs (SC-5) The dogfood gate runs only on PRs/pushes, so a CVE disclosed after an image was published went unnoticed until the next unrelated build — during any quiet period the shipped :latest/:dev could carry a fixable HIGH/CRITICAL with nobody looking. Add rescan.yml: weekly (and on demand) it pulls each published image and re-runs the SAME Trivy + Grype gate the dogfood self-scan uses (fixable HIGH/CRITICAL, bundled trivy/grype/syft binaries excluded, same triage ignorefiles). It doesn't gate a merge — on a finding it opens, or comments on, a per-tag tracking issue (deduped by title). A leg whose tag isn't published yet skips cleanly. This also automates the bundled-binary CVE treadmill check the archive did by hand. README documents it. * build(docker): cosign-verify scanner checksum files before extraction (SC-8) Each scanner tarball was verified against a checksums.txt downloaded from the same GitHub release — which defeats transit corruption but not a compromised release, since an attacker who can replace the tarball can regenerate the matching checksum beside it. Add keyless cosign verification of each publisher's signature over its checksums.txt before the existing sha256sum check: cosign is pinned by digest from the official Sigstore ko image (COPY --from), and each verify-blob pins the GitHub Actions OIDC issuer and an identity regexp scoped to the upstream repo's release workflow (aquasecurity/trivy, anchore/grype, anchore/syft). This raises the guarantee from "matches what GitHub serves" to "signed by the upstream project's own release pipeline" (Fulcio identity + Rekor inclusion). The download → cosign-verify → sha256sum → extract order and the concurrent-subshell build-performance shape are preserved; a bad signature fails the build. Note: the upstream signature asset names and certificate identities could not be exercised in the egress-restricted prep environment (the release CDN is blocked); the CI image-build job validates them end-to-end. * fix(secrets): bind field-encryption AAD to the row, not just the column (SEC-7) Each stored secret's AAD was the (table, column) tag, so a ciphertext could be relocated between two rows of the same column and still authenticate (swap one registry's secret into another, or one user's MFA seed into another user's row). The threat needs DB write access — outside the DB-read model §6 defends — but is cheap to close. encrypt_secret/decrypt_secret now accept an optional row_id: when given, the AAD becomes "<table>.<column>:<row-id>". decrypt tries the row-bound tag first and falls back to the bare column tag, so this needs NO migration — every secret written before row binding still decrypts, and each upgrades to row binding the next time it's written. Call sites thread the row id (MFA and the OIDC singleton bind immediately since their rows pre-exist; API-resource create paths bind on first update, since the id doesn't exist pre-flush). The backup re-wrap preserves each value's existing binding across a build/restore cycle rather than silently upgrading it. Passing row_id=obj.id is None-safe (None -> column-only), so no create-flow reordering was needed. Tests: row-bound round-trip, a row-42 blob refuses to decrypt as row 43, and a legacy column-only blob still decrypts when the reader passes a row_id. * fix(oidc): audit mandatory-MFA delegation on OIDC logins (SEC-8) The mandatory-MFA policy (required_all / required_admin) is enforced only on local password login; the OIDC callback delegates the second factor to the IdP. That is an inherent, documented limitation — OIDC accounts have no local TOTP to challenge, and forcing one in the redirect flow (or blocking the login) can be wrong when the IdP already performed MFA, so the review recommends no behavioral change and enforcement at the IdP. Rather than change auth behavior, add operator visibility: when a mandatory policy would require a second factor for the user's role, the OIDC login records `mfa_delegated_to_idp` in the audit log, so an operator running mandatory MFA can see which logins relied on the IdP and confirm it enforces MFA. Auth behavior is unchanged; README security model documents the delegation and the audit signal. * fix(auth): audit policy-forced MFA enrollment distinctly (SEC-9) When a mandatory-MFA policy applies to an account that has never enrolled, enroll-on-first-login lets whoever holds the password complete the first-factor setup — so in the window before the legitimate user enrolls, a password-only attacker could bind their own authenticator. This is inherent to self-service enrollment; the durable fix is out-of-band/admin-provisioned enrollment (a feature), and blocking self-enrollment would break mandatory MFA for everyone. Short of that, make the window auditable: the policy-forced first-enrollment completion (the one code path reached only from a password login's forced-enroll challenge) now records `forced_by_policy` on its auth.mfa_enabled event, so an admin can detect an unexpected enrollment and respond. README security model documents the window and the mitigation. * fix(ratelimit): bound rate-limiter and pending-MFA memory growth (SEC-10) The sliding-window limiter pruned each key's event deque on access but never evicted idle keys, so a stream of distinct real client IPs grew the backing dict without bound; PendingMfaStore likewise had no per-user cap on concurrent challenges (bounded only by the shared IP limiter and the 300s TTL). The limiter now sweeps fully-expired keys once the map grows past a threshold, amortized to at most once per N events so it stays O(1) per call; the sweep runs after the in-flight key's window is finalized so the current key is never mistaken for idle. PendingMfaStore caps concurrent challenges per user, dropping the oldest when a new one would exceed the cap (other users unaffected). Tests cover idle-key eviction, active-key survival, and the per-user cap. * build(docker): digest-pin the BuildKit syntax frontend (SC-9) `# syntax=docker/dockerfile:1.7` resolved the BuildKit frontend by mutable tag at build time — the one build component not pinned by digest. Pin it to the current 1.7 manifest digest (keeping tag + digest together), consistent with the base images and scanner binaries. A guard test asserts the syntax directive stays digest-pinned. * docs: log the security + supply-chain review batch in ARCHIVE deviations Records the H11/M2–M5/M22–M26/L1–L4/L23 remediation batch (per-finding), the stop-and-ask decisions (uv lockfile, entropy-floor crypto, tecnativa bump), the new operational knobs (SCRYE_ALLOW_INTERNAL_EGRESS, SCRYE_ALLOW_WEAK_MASTER_KEY), the additive at-rest hardening (row-bound AAD, no format change), and the CI-validated items (M26 cosign asset names/identities). * style: sort the app_settings import added for the OIDC MFA audit (SEC-8) Follow-up isort fix for the import introduced in the SEC-8 commit; no behavior change. * build(docker): use the correct per-vendor cosign verification (SC-8 fix) The initial SC-8 cosign step assumed all three scanners publish detached certificate (.pem) + signature (.sig) files, which broke the CI image build: Aqua/Trivy signs each artifact with a Sigstore protobuf bundle (`trivy_<ver>_checksums.txt.sigstore.json`), not a .pem/.sig pair, so the `.sig`/`.pem` downloads 404'd. fetch_verify_extract now takes a signature style: Anchore (grype/syft) keeps the `--certificate <.pem> --signature <.sig>` path (those assets are confirmed present), and Aqua (trivy) verifies the bundle with `cosign verify-blob --bundle <checksums.txt.sigstore.json> --new-bundle-format`. cosign is bumped to v2.6.1, which unambiguously supports `--new-bundle-format` (Trivy documents that cosign v2 requires that flag for the bundle format). Verified the RUN block's shell parses (`sh -n`); the real end-to-end verification runs in CI, which can reach the release assets. * build(docker): match Anchore's main-branch signing identity (SC-8 fix) CI showed the per-vendor split works — Trivy's Sigstore bundle verified OK — but grype/syft failed identity verification: Anchore signs its release checksums from `release.yaml@refs/heads/main` (the main-branch ref), while the regexp required a `@refs/tags/v…` ref. Broaden the Anchore identity regexp to accept either a heads or tags ref (still pinned to the anchore/grype and anchore/syft repos and the GitHub Actions OIDC issuer). Trivy's tag-ref regexp is unchanged (it passed).
* docs: update dead docs/PLAN.md references to docs/ARCHIVE.md The build spec was renamed from docs/PLAN.md to docs/ARCHIVE.md, leaving ~100 dead cross-references in backend docstrings, frontend comments, and the Dockerfile. Sweep them all to docs/ARCHIVE.md; section anchors are unchanged since ARCHIVE.md kept the original section numbering. Comments/docstrings only, no logic change. * docs: correct stale 'no registry publishing' comments to match §6 docker/Dockerfile and docker/docker-compose.yml still described the original local-build-only distribution model and cited 'no registry publishing'. Locked decision §6 has since been revised to publish images to GHCR (ghcr.io/tyler-rich/scrye) via the release and nightly workflows. Correct the three comments to match current reality. Comments only. * docs(archive): log §14 deviations for squash-merge authorship and promotion-title convention Add two dated §14 (§ Deviations) entries so the CLAUDE.md rules amended in the same docs-compliance pass are backed by the deviation log like the others: - Squash-merge authorship reflects the merging account's GitHub profile display name (git config user.name cannot override it) — the doc-side counterpart to compliance finding D4. - dev→main promotion PRs use a plain 'Promote dev to main: …' title instead of a Conventional-Commit prefix, per CONTRIBUTING.md § Releasing. * docs(claude): align CLAUDE.md rules with current reality (R1–R5, R7, R8) Amend eight operating-contract rules the code has knowingly outgrown, each backed by a dated docs/ARCHIVE.md § Deviations entry, so a future session does not 'fix' compliant code back toward the abandoned plan: - R1: hand-written typed API client in frontend/src/api/ (not OpenAPI-generated) — FE-2. - R2: dogfood gate is fixable HIGH/CRITICAL, not all-severities — INF-10. - R3: frontend uses Vitest (npm test) covering the lib/ polling/url/arrays/latest helpers. - R4: OIDC client secret and other stored secrets live field-encrypted in the DB, not as .env.example placeholders. - R5: list the additional maintained deliverables (CHANGELOG, SECURITY.md, CODEOWNERS, ROADMAP, dependabot.yml, ci/ allowlists). - R7: note that squash-merge authorship uses the GitHub profile display name. - R8: promotion PRs use a plain 'Promote dev to main: …' title (exception to Conventional Commits). Also add a § Dependency hygiene pointer to the requirements.lock workflow (regenerate with the pinned 'uv pip compile --generate-hashes' on any pyproject dependency change; CI fails on drift) per CONTRIBUTING.md § Backend dependency lock.
Read-only verification of PRs #50-#65 against the current merged state of dev. Confirms 10/11 HIGH, 26/26 MEDIUM (as addressed), and 24/25 LOW findings resolved with covering tests, and that the five mid-batch decisions (M2, M11, M23, H11, CON-11) landed as decided. Flags the one genuine miss (H5/CON-4, scanner JSON parse still on the event loop, with no deviation entry), triages the two Dependabot default-branch alerts as already-fixed-on-dev-pending-promotion, and notes the L24/SC-11 and M19-untested residuals.
) * fix: hop scanner JSON parse/normalize off the event loop (CON-4) Each scanner's `_execute` parsed and normalized the full scanner report inline on the event loop. A large report (stdout is capped at 512 MiB; the archive's own run produced 7,072 findings) is seconds of pure CPU, freezing every coroutine during the parse — including the /healthz poll the container healthcheck restarts on. Run the parse in a worker thread via `anyio.to_thread.run_sync`, reusing the same primitive CON-5 used for blocking DB work. Both scanners' `parse_output` call the shared `load_json_output`, so hopping `parse_output` moves the json.loads and the normalization loop off the loop in one place; the parsing logic is untouched. Adds a regression test proving a slow (large-report) parse no longer starves the loop — a heartbeat coroutine keeps ticking while a blocking stand-in parse runs — plus thread-identity assertions for both engines. Records the fix (and the previously-absent record of this finding) in docs/ARCHIVE.md §14. * ci: set persist-credentials: false on ci.yml checkouts (SC-11) The token-bearing publish workflows already drop the persisted checkout token, but ci.yml's four checkout steps were missed. CI only needs contents: read and no step pushes or authenticates to the remote, so clear the token rather than leave it in .git/config for later steps. Closes the L24/SC-11 residual.
tyler-rich
added a commit
that referenced
this pull request
Jul 20, 2026
…w-remediation batch (#74) Re-verifies every finding across all six original code-review reports (including findings never carried into 00-summary.md) against the current state of dev, after the H5/CON-4 follow-up (#67) and the dev->main promotion (#70). Records the current STILL-OPEN backlog (frontend Priority-3 batch, SC-12/SC-14, D5b, test debt), deferred-by-decision items with their tracking refs, a resolved index, and the ARCHIVE.md section-14 entry gaps (#53 H1/SEC-1, #57 H9+H10, #65 D1/D2/R1-R6, #59).
tyler-rich
added a commit
that referenced
this pull request
Jul 31, 2026
The [0.2.0] section carried only what had been written into [Unreleased] since roughly 2026-07-24. Everything promoted in #70 (2026-07-13, i.e. #53-#67) and the #77-#88 batch that followed had never been changelogged at all — v0.1.0 was tagged 2026-07-09 and #70 landed four days later — so about two dozen PRs of security and correctness work that ships in 0.2.0 was absent. Backfilled from the #70 commit range and the §14 entries for that batch, merged into the existing Added/Fixed/Changed/Security sections rather than added as a separate block: 0.2.0 is one release, and a changelog-within-a-changelog would make a reader track which half applies to them. Three of the release's upgrade-affecting items live here and were invisible before: the SSRF egress guard (SCRYE_ALLOW_INTERNAL_EGRESS, default off), the remote-clone-URL requirement for repository targets, and the master-key entropy floor — which refuses to start a v0.1.0 deployment whose key file holds a raw passphrase, and whose remedy is the boot-and-rotate escape hatch plus a backup/restore cycle, not a fresh key. Also records three contract-visible API changes narrower than the envelope: timestamps serialize with an explicit Z, /api/audit renamed entries -> items, and scan list rows dropped options/error in favour of has_error.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promotes
devintomainto cut a release from the security / code-review remediation batch. This is a deliberatedev→mainpromotion, not a feature merge — merge it as a regular merge commit (do not squash) so the individual remediation commits are preserved onmain.devis currently 44 commits ahead ofmainwith no commits onmainthatdevlacks (merge-base ==maintip —mainis strictly behind, no divergence). CI is green across the batch.What's in this batch
The work resolves the findings from all six
/code-reviewreports (consolidated indocs/reviews/), plus the follow-on docs/compliance and verification passes:persist-credentials: false), dogfood self-scan CVE remediation.docs/reviews/fix-verification.md) confirming each fix landed.Deferrals
The two intentionally-deferred items are tracked, not dropped:
docs/ROADMAP.md.Post-merge
Per
CONTRIBUTING.md§ Releasing: tagmain(e.g.v0.x.0) to trigger the release publish, then back-mergemainintodev.