Skip to content

Promote dev to main: v0.2.0 — Python 3.14.6 runtime, wollomatic socket proxy, {total, items} list envelope, review remediation, onboarding fixes - #122

Merged
tyler-rich merged 85 commits into
mainfrom
dev
Aug 1, 2026
Merged

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Promotes dev to main for the v0.2.0 release. main has had nothing since #70 (2026-07-13) apart from the #110 brace-expansion security merge.

Merge with a regular merge commit — "Create a merge commit", not "Squash and merge". This is now the documented convention for promotions (CONTRIBUTING.md § Releasing step 1, changed in this PR — see Process changes below).

Do not merge until reviewed — tagging is a separate maintainer step after this lands.

What this release contains

Runtime and platform

API

  • Thirteen list endpoints now return the shared {total, items} envelope (feat(api): standardize list responses on a shared {total, items} envelope #90). Breaking for API-token consumers — the reason this is a minor, not a patch.
  • GET /api/scans deprecated in favour of GET /api/scans/history; its response shape is unchanged.
  • Three narrower contract changes from the API review: timestamps serialize with an explicit Z, GET /api/audit renamed its envelope key entriesitems, and scan list rows carry has_error instead of options/error.

Onboarding fixes from user reports

Security and supply chain

The CHANGELOG was missing half of this

[0.2.0] originally carried only what had been written into [Unreleased] since roughly 2026-07-24. Everything promoted in #70 and the #77#88 batch after it 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 this release 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. Three of the release's six upgrade-affecting items live in that backfill and were invisible before it: 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 will refuse to start a v0.1.0 deployment whose key file holds a raw passphrase.

Process changes in this PR

  • Promotions now merge with a merge commit, not a squash (CONTRIBUTING.md § Releasing step 1, CLAUDE.md § Git & PR conventions). Feature PRs into dev are unchanged and still squash-merged. The three passages that assumed squashing — both back-merge descriptions and the git-identity note — were corrected in the same pass. The argument is the divergence this PR had to reconcile; see below.
  • Dependabot security updates open against main, not dev, ignoring target-branch, and retargeting such a PR does not work. Documented in both files after ci: bump brace-expansion from 1.1.16 to 1.1.18 in /frontend in the npm_and_yarn group across 1 directory #120.

A conflict had to be reconciled before this PR could merge

main was not an ancestor of dev: #110 was merged directly into main, and #119 replicated its change on dev as a separate commit rather than merging main. Both branches had therefore independently edited frontend/package-lock.json, and this PR opened as dirty.

Resolved by merging origin/main into dev (commit 05219b7), taking dev's side of the lockfile. Verified this cannot resolve away from #110's security fix: no package is present on main and absent from dev, and the only version differences are the app version (0.1.0 → 0.2.0) and brace-expansion 1.1.16 → 1.1.18 / 2.1.3 → 2.1.4 — both moving forward past what main carries. The net diff of the resolved merge against dev's pre-merge tip is empty, which is the check CLAUDE.md § Git & PR conventions requires.

This is exactly what the merge-commit change above prevents recurring.

Why this also fixes CI and the scheduled workflows

main's push CI is currently red at 086fb1e: its image job still builds on Python 3.13.14 and trips the Grype gate on CVE-2026-11940 (tarfile) and CVE-2026-15308 (html.parser), both HIGH. dev waived both in #117 with dated source-verification and moved to 3.14.6 in #91, so the promotion is what returns main to green — the red is staleness, not a regression this PR introduces.

Scheduled and tag-triggered workflows run from the default branch's copy of themselves, so dev-nightly.yml and rescan.yml have been executing versions that predate every workflow change on dev. ci.yml and publish.yml are likewise stale on main. The promotion lands all four.

Before-you-tag checklist

Worked per CONTRIBUTING.md § Releasing:

  • CHANGELOG.md reviewed in full and re-verified claim by claim against the tree — the false-CVE correction is present and accurate; one sentence amended where "all four stay waived" had become misleading as a total. [Unreleased] cut to [0.2.0] - 2026-07-31 with a fresh empty [Unreleased] above it and a [0.2.0] compare link, so main receives a correct CHANGELOG rather than a commit landing on it after the tag.
  • THIRD_PARTY_LICENSES/ re-verified — version table matches the Dockerfile args, and all four bundled files re-fetched from upstream at those tags and compared byte-for-byte.
  • No open Dependabot PRs. ci: bump brace-expansion from 1.1.16 to 1.1.18 in /frontend in the npm_and_yarn group across 1 directory #120 was closed and its bump reapplied on dev in chore(release): v0.2.0 release prep — CHANGELOG cut, brace-expansion bump, Dependabot security-PR routing #121.
  • backend/requirements.lock needs no regeneration — pyproject.toml dependencies are unchanged since the last compile, and CI's drift gate is green.
  • Version string reads 0.2.0 everywhere it is declared or displayed; backend/tests/test_version.py binds them and passes. The README's "pin a release" examples no longer advertise a :1.4.0 that does not exist.

Open dependency findings, none release-blocking

Given live tracking issues so they do not go invisible between npm audit runs:

See docs/ARCHIVE.md § Deviations for the two dated entries covering this cycle.

tyler-rich added 30 commits July 4, 2026 03:57
…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.
Brings the dev/main branching-model docs (from main, #17) into dev and
resolves the docs/PLAN.md Deviations-log conflict by keeping both 2026-07-04
entries (Docker Hub publishing + dev/main branching model). Unblocks the
dev -> main promotion PR (#19).
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.
Bring main's PR #19 squash-merge commit (b771d98) into dev's ancestry so
dev is no longer reported behind main after the squash merge. dev and main
are already byte-identical in content; this merge changes no files and only
reconciles history. No other work is bundled in.
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
  (170156756+tyler-rich@users.noreply.github.com; 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
tyler-rich and others added 25 commits July 25, 2026 23:47
…bian run (#96)

The wollomatic migration (#63) left one item open: boot the published image on a
Docker-capable host and confirm DOCKER_GID is right for it. That run happened.
The allowlist behaved exactly as documented -- digest-pinned image up under
65534:<gid> with a read-only rootfs, GET /images/json and /v1.NN/images/json 200,
/info + /containers/json + /version 403, POST 405. Four operational behaviors it
surfaced were not documented, or were documented wrongly:

- DOCKER_GID was 989 on the host, not the 999 the Compose fallback assumes. The
  README called 999 "the Debian/Ubuntu default", which reads as probably-right;
  it is a packaging convention and was wrong here. Reframed as a placeholder,
  with the measured value cited. The stat derivation is unchanged.
- The -allowfrom source check is evaluated before the path and method rules, so
  a request from a non-matching source gets 403 for everything, including
  methods that would otherwise answer 405. A wrong source and a disallowed path
  are indistinguishable from the client, which sends anyone debugging "403 on an
  endpoint that should work" at the -allowGET regex instead of the source check.
  The proxy log does distinguish them (blocked request ... forbidden IP).
- A wrong GID crash-loops the sidecar rather than stopping it: it logs
  dial unix /var/run/docker.sock: connect: permission denied, exits, and
  restart: unless-stopped retries it under Docker backoff. STATUS reads
  Restarting, never Exited. The README said it "stays down".
- From the client that crash loop is a connection error, not an HTTP status
  (curl reports 000); list_images() raises from httpx.HTTPError rather than from
  a non-200. Both surface as 502, so the detail text -- not the status code --
  is what separates a broken sidecar from a restrictive allowlist.

Also re-confirmed structurally that a failing proxy cannot block app startup: no
depends_on exists in any Compose file, for any service, in any profile, so there
is no service_healthy gate and the proxy healthcheck is consumed by nothing but
itself. Scrye starts and stays up while the sidecar cycles.

Docs only -- no code, Compose, CI, or configuration change; the -allowGET
pattern and the sidecar option set are untouched. See docs/ARCHIVE.md
§ Deviations (2026-07-26), which also tracks the two non-doc spots left alone
by that scope.
…and Compose comment (#97)

The non-200 detail in docker_proxy.list_images() advised checking that the proxy
is "read-only with IMAGES=1" — a tecnativa env var that has not existed since the
wollomatic migration (#89), printed in exactly the 403 case the README teaches
operators to diagnose. It now names the real causes: a non-allowlisted path
(-allowGET permits only /images/json and /v1.NN/images/json), a non-GET method,
or the -allowfrom source check rejecting a non-scrye client, with the note that
the proxy's log distinguishes them, and a pointer to the README rather than a
restatement of it.

The docker-compose.yml DOCKER_GID comment called 999 "the Debian/Ubuntu default";
it now matches the README's corrected wording (a placeholder, not a safe default;
989 on the host this was verified against) and describes the wrong-GID failure as
the crash loop it actually is. The stat -c '%g' derivation remains the instruction
and the ${DOCKER_GID:-999} value is unchanged.

Adds a regression test driving the 403 path through httpx.MockTransport. No
existing test asserted on the old string.

See docs/ARCHIVE.md § Deviations for the dated entry retiring both follow-ups.
…vers at #98 (#99)

Re-checked CVE-2026-15308 and CVE-2026-12003 against the CPython `3.14`
branch and the `v3.14.6` tag rather than Grype's FIXED IN column, per
CLAUDE.md § Dependency hygiene. Both fixes are merged to the 3.14
maintenance branch (python/cpython#153039, #151682) and absent from
3.14.6, so 3.14.7 is the first release carrying either. Both waivers
stand; the 2026-10-25 review date is unchanged.

The Group A block already read "next 3.14.x" — no trigger correction was
needed. What it lacked was the named release, a record of the fixes being
verified rather than assumed, and a tracking reference that matches the
current runtime: the shared header pointed all four CVEs at issue #52,
which is 3.13-scoped and stale since the 3.14 move. Group A now cites the
new Group-A-only issue #98; the header no longer claims one tracker for
both groups.

Group B is untouched.

See docs/ARCHIVE.md § Deviations (2026-07-26) for the full record.
…b backport landed on 3.14 (#100)

Group B's rationale — upstream declined the backport to 3.10-3.14, so
these are fixed only in 3.15+ — is no longer true of CVE-2025-15366.
Lib/imaplib.py on the 3.14 and 3.13 branches carries the _control_chars
guard in IMAP4._command() (python/cpython#153137, commit 2981822, merged
2026-07-07); v3.14.6 does not, and the gh-143921 NEWS entry is still
under Misc/NEWS.d/next/Security. That is Group A shape: merged, unreleased,
closing on 3.14.7.

poplib is unchanged — its guard is on main only, and gh-143923 lists no
backport PR. The two diverged because imaplib's check was narrowed to
NUL/CR/LF (python/cpython#153067), clearing the regression concern behind
the original decline; poplib's still rejects the full control-character
range.

Group B keeps its standing-acceptance framing, its annual review date
(2027-07-25), and the do-not-scope-a-3.15-move instruction, now for
CVE-2025-15367 alone. The waiver list is unchanged at four entries.

The 2026-07-25 entry is superseded on this one point and left unedited;
its reading of 3.14.6 was correct, but the backport had already merged
18 days before it was written.

See docs/ARCHIVE.md § Deviations (2026-07-26) for the evidence.
…work) (#101)

Read-only audit producing findings and proposals only; no code, config, or
existing document is changed.

- Part 1: true status of all five open issues against the code on dev — #63
  and #83 are fully resolved (#89, #87) and only open because closing keywords
  never fire on a dev-targeted PR; #98 and #52 are live waiver trackers; #75
  needs a release.
- Part 2: full recursive docs/ inventory with a KEEP/ARCHIVE/DELETE
  disposition per file, the inbound-reference blast radius for each proposed
  removal, and a proposed docs/history/ structure.
- Part 3: consolidated list of deferred work still outstanding in ARCHIVE.md
  section 14, separating items with a live tracking reference from the three
  recorded in prose only.
- Part 4: ARCHIVE.md navigability (including 14 deviation entries misfiled
  under the Build performance heading), ROADMAP items now done, the
  Unreleased changelog backlog, 33 prunable merged branches, and proposed
  CLAUDE.md wording for two operational lessons.
…s structure, correct a false CVE claim in the CHANGELOG (#102)

* docs(archive): make §14 contiguous, add a newest-first index, and add the finding-ID decoder

Twelve dated §14 entries — every one from 2026-07-09 onward, including all the
recent work — sat underneath `## Build performance` rather than under §14, so
anyone scrolling §14 to the end stopped short of them. Moved the Build
performance section to the end of the file instead of re-parenting the entries:
it is self-contained and cross-referenced by heading name (from CLAUDE.md and
four workflows), not by position, so nothing breaks. All 104 dated entries are
now under §14.

Added a newest-first index at the top of §14, one anchored line per entry. The
entries themselves are deliberately NOT reordered: sixteen of them refer to each
other relatively ("the entry below", "superseded by the entry above"), and a
sort would silently invert every one. The three ordering regimes are documented
instead, and the index is sorted by date regardless of physical position, so
lookup no longer depends on the scroll order.

Added §15, a finding-ID index: one row per SEC/SC/APIR/CON/P1-P3/D/R/QUA/INF/
FE/API/FEAT/DOC/SCN id with a one-line description and its resolving PR. §14
cites these ids bare and never re-explains them; this is the decoder that
replaces the docs/reviews/ reports. It also records the SEC-* prefix collision
between the two reviews that reused it.

Corrected §0 locked decision #7: it said CVE-2025-15366 and CVE-2025-15367 are
both unfixable on 3.14. That is true of released 3.14.6 but not of the 3.14
line — the imaplib backport landed on the maintenance branch and closes on
3.14.7 (issue #98). Only the poplib CVE remains 3.15-only (#52).

* docs(changelog): correct the CVE-2025-15366 claim under [Unreleased]

The Python 3.14 entry said all four waived CPython CVEs remain unfixable until
3.15 because upstream declined the backport to 3.10-3.14, and pointed at issue
#52 for all of them. Both halves are false, and this text ships verbatim as the
next release's notes.

Checked against ci/grype.yaml and the two 2026-07-26 §14 entries: the imaplib
backport for CVE-2025-15366 merged onto the CPython 3.14 branch on 2026-07-07 —
18 days before the entry was written — so it closes on 3.14.7, not 3.15, and it
was regrouped into Group A alongside CVE-2026-15308 and CVE-2026-12003, tracked
in issue #98. Only CVE-2025-15367 (poplib) is genuinely 3.15-only and still
tracked in #52.

What was true and is kept: released 3.14.6 carries neither guard, so the upgrade
cleared nothing at the pinned version.

* docs: strike completed roadmap items, surface the settings-level work, add two process rules

ROADMAP:
- Struck "Pin GitHub Actions to commit SHAs" (done in #57 — ci.yml has 8
  SHA-pinned uses:, dev-nightly 3, publish 3, rescan 2) and "Frontend test
  runner" (done in #78 — vitest 3.2.7, "test": "vitest run", 20 test files).
- Rewrote "Row-bound secret AAD", which was false as stated: row binding is
  implemented (secret_store.py row_aad(), L1/SEC-7, #64) and every write is
  row-bound. What remains is only the bulk re-encryption of legacy column-only
  ciphertext so the read fallback can be dropped, so it is folded into the
  existing "Admin bulk secret re-encryption" item.
- Extended the public-repo governance checklist with five settings-level items
  that existed only in §14 prose and were therefore invisible: Actions workflow
  permissions -> read-only, confirm GHCR package visibility is public (the
  original check asked for Private and its premise inverted when the repo went
  public), delete the unused DOCKERHUB_* secrets, set the GitHub profile display
  name to tyler-rich, and confirm Dependabot security alerts are on.

CLAUDE.md § Git & PR conventions gains two rules learned the hard way: a stacked
child PR retargeted after its parent was squash-merged needs git rebase --onto
(flipping the base in the UI re-computes the merge base and balloons the diff),
and on: pull_request does not fire on 'edited', so a base change never re-runs
CI and the green check you are looking at is from the old base.

CONTRIBUTING § Releasing gains a "Before you tag" checklist — CHANGELOG
[Unreleased] reviewed (it ships verbatim), THIRD_PARTY_LICENSES verified against
the versions actually pulled, Dependabot PRs triaged, requirements.lock
regenerated — plus the two after-tag steps: back-merge main into dev, and re-run
rescan.yml.

* docs: delete docs/reviews and docs/upgrades, sweep every inbound reference

The twelve review reports and the Python 3.14 handoff doc held only closed
findings, and sat at the same directory level as the two live documents. They
are removed rather than moved to a docs/history/ subtree: the only real argument
against deleting them was that §14 cites their finding ids bare and never
re-explains them, and §15 (previous commit) answers that directly. Git is the
archive for the rest. docs/ now contains exactly ARCHIVE.md, ROADMAP.md, and
screenshots/.

The originals stay retrievable — the pre-deletion commit is
0780b07, and the §14 entry records the
git show incantation. Nothing was rewritten before deletion.

Swept every inbound reference the audit enumerated, plus the ones it did not:
CONTRIBUTING § Project layout (both directories dropped, screenshots/ added),
CONTRIBUTING § API conventions, CHANGELOG's L13/APIR-8 citation, and both
dependabot.yml D3 comments now point at ARCHIVE §15. Inside §14, 59
docs/reviews/ and 7 docs/upgrades/ path prefixes were stripped so the entries
name the reports as documents rather than as paths that no longer resolve, with
a note at the top of §14 sending the reader to §15. Fixed the dead
claude-md-compliance-review.md link (a filename that never existed). Left one
docs/upgrades/ mention deliberately: the 2026-07-25 entry's record of what
CONTRIBUTING's layout listing omitted is a statement about that date, marked
'(as it then was)'.

Verified no workflow, test, or source file referenced either directory, and that
the four '§ Build performance' cross-references are by heading name and survive
that section's move.

Added the dated §14 entry recording all of it, including that docs/history/ was
considered and rejected, and two corrections to the audit: twelve entries were
misfiled under Build performance (not fourteen — the other two are that
section's own sub-headings), and there are 42 remote branches with 36 prunable
(not 39/33).
…103)

- actions/checkout 7.0.0 -> 7.0.1
- actions/setup-python 6.3.0 -> 7.0.0
- actions/setup-node 6.4.0 -> 7.0.0
- docker/login-action 4.4.0 -> 4.5.1
- actions/attest-build-provenance 2.4.0 -> 4.1.1

Every pinned SHA was verified against the upstream tag it claims. The two
runner-action majors drop inputs we do not use (setup-python's pip-install,
setup-node's dummy NODE_AUTH_TOKEN export) and are otherwise ESM/dependency
refreshes.

attest-build-provenance is the only bump with real semantics: v3 moved to the
node24 runtime and added create-storage-record (default true), and v4 reduced
the action to a wrapper over actions/attest. Storage records are only created
for organization-owned repos, so this user-owned repo skips that path and needs
no artifact-metadata: write scope; the subject-name/subject-digest/
push-to-registry inputs are unchanged. Note both facts inline next to the step.
#104)

Rolls the pinned digest for the scanner-download stage forward to the
current debian:bookworm-slim (verified against the registry: the tag
resolves to 7b140f37... today). That stage curls and checksum-verifies the
trivy/grype/syft binaries, so its curl and CA bundle should not drift; none
of its layers ship in the runtime image.

Split out of the grouped Dependabot docker-images bump, whose other half
(node 22 -> 26 for the frontend builder) is a major on a non-LTS Node line
and is evaluated separately.
…5 updates (#105)

Runtime:
- fastapi 0.139.0 -> 0.140.0
- pydantic-settings 2.7.1 -> 2.14.2
- alembic 1.14.0 -> 1.18.5

Dev-only:
- pytest 9.0.3 -> 9.1.1
- black 26.3.1 -> 26.5.1

Regenerates backend/requirements.lock with the pinned uv command from
CONTRIBUTING § Backend dependency lock (uv 0.8.17,
uv pip compile pyproject.toml --group build --generate-hashes
--python-version 3.14), which the Dependabot PR did not do — without it CI's
lock-drift gate fails. The lock diff is confined to the three bumped runtime
packages plus a new typing-inspection 'via' edge; no new distributions enter
the closure.

Verified on CPython 3.14.6 against the regenerated lock: 580 passed, 3
skipped; ruff and black --check clean under black 26.5.1; alembic upgrade
head and downgrade base both run end to end under 1.18.5; and regenerating
.env.example from the Settings model produces no diff, with init > env >
dotenv precedence and the NoDecode comma-split fields still behaving as
documented.
…ion §2) (#106)

The npm entry now ignores major updates for @mantine/*, react, react-dom,
@types/react and @types/react-dom. Minor and patch updates inside Mantine v7
and React 18 still flow as before, and no other ecosystem entry changes.

CLAUDE.md locked decision §2 fixes the frontend at React 18 + Mantine v7, but
Dependabot had no way to know that, so the weekly grouped frontend PR proposed
@mantine/* 7.15.2 -> 9.4.2 and react/react-dom 18.3.1 -> 19.2.8 alongside a
dozen updates that were fine. One unmergeable entry makes the whole grouped PR
unmergeable, and closing it discards the mergeable bumps with it — so the lock
has to be expressed in the config or it gets re-litigated every week.

@types/react* are included because their major tracks React's: @types/react 19
against react 18 fails the type-aware ESLint gate enabled 2026-07-24.

See docs/ARCHIVE.md § Deviations for the dated entry.
… Node 24 and frontend-tooling work (#107)

Rolls the frontend-builder base digest forward to the current
node:22-bookworm-slim. Verified against the registry rather than taken from
bump metadata: registry-1.docker.io resolves the 22-bookworm-slim tag to
6c74791e..., that index carries both linux/amd64 and linux/arm64, and Docker
Hub's tag listing shows the same digest on 22.23.1-bookworm-slim and
jod-bookworm-slim, which is what establishes the version behind the moving
tag. No stage boundary, layer ordering, or cache scope changed.

Same case as the debian refresh in #104: Dependabot's docker-images group
offers node 22 -> 26, a major on a line that is not LTS, not a digest refresh
within 22, so declining the major left the pin stale. Node 22 is supported
through 2027-04-30; 24 (Active LTS, through 2028-04-30) is the target and 26
is not LTS until 2026-10-28.

Also records three items that had been decided but written down nowhere a
reader would find them, in docs/ROADMAP.md near-term: the Node 22 -> 24 move
(Dockerfile + ci.yml + CONTRIBUTING together), the frontend tooling majors
left over from Dependabot #86 after the Mantine/React ignores (TypeScript
5.7 -> 7.0, ESLint 9 -> 10, typescript-eslint 8.65, Vite 6 -> 8, Vitest
3 -> 4, jsdom 26 -> 29, all landing on the type-aware ESLint gate), and the
deprecated Starlette HTTP_422_UNPROCESSABLE_ENTITY / HTTP_413_REQUEST_ENTITY_
TOO_LARGE constants at 22 and 2 call sites.

See docs/ARCHIVE.md section 14 for the dated entry.
… NAS platforms (#108)

Synology Container Manager and QNAP Container Station reject or mishandle
`deploy:` keys, so docker-compose.yml would not deploy there at all, even though
Compose v2 honours the block standalone. All three services carried a
deploy.resources block (and a fourth copy sat in the README's paste-in stack).

Memory limits stay on by default, re-expressed with the portable mem_limit /
mem_reservation keys: scrye 2g + 256m reserved, trivy-server 1g,
docker-socket-proxy 64m. Memory is the containment control that matters most —
it bounds the OOM blast radius, and the RAM-backed /tmp tmpfs is charged
against it.

CPU limits move to a new opt-in overlay, docker/docker-compose.cpu-limits.yml,
rather than being deleted; the caps are a hardening measure and the 0.5 cap on
the socket proxy (the only container mounting docker.sock) is deliberate. The
overlay uses the portable `cpus:` key, not `deploy:` — Compose rejects a merged
project whose overlay contributes deploy.resources.limits against the base
file's mem_limit ("can't set distinct values on 'mem_limit' and
'deploy.resources.limits.memory'"), so a deploy-based overlay would not apply.

`cpus:` is the same limit, not a weaker one: Compose refuses a project that sets
`cpus` and `deploy.resources.limits.cpus` to different values, which a
parsed-and-ignored key could not do.

Verified with `docker compose config` across base-alone and base+overlay, each
with and without the sidecar profiles; the merged config reproduces the
pre-change limits exactly. Adds five regression guards to
test_compose_hardening.py covering both halves of the split.

See docs/ARCHIVE.md § Deviations for changes made in this pass.
…g an existing one (#109)

* feat(crypto): generate the master key on first launch, never replacing an existing one

A new deployment could not start until the operator had produced a master key by
hand. Because Compose validates a file-backed `secrets:` entry before it reads
the rest of the stack, a missing `secrets/app_secret_key` failed
`docker compose up` outright rather than producing a startup error anyone could
act on — a first-run blocker rather than a misconfiguration.

With no key supplied, Scrye now mints one from the OS CSPRNG (48 random bytes
base64-encoded — the documented `openssl rand -base64 48` equivalent, so it
clears the SEC-3 entropy floor without the weak-key opt-out), writes it
`O_CREAT|O_EXCL` + fsync, chmods it 0600, re-stats it to verify mode and owner,
and logs one INFO line naming the path and warning that the file must be backed
up. Two new settings: SCRYE_APP_SECRET_KEY_AUTOGENERATE (default true) and
SCRYE_APP_SECRET_KEY_AUTOGEN_FILE (default /data/app_secret_key).

Precedence is preserved with generation strictly last: the Docker secret at
SCRYE_APP_SECRET_KEY_FILE still wins, then a previously generated key file, then
generation. No key is ever read from an env var or an image layer, and the KDF
and token format are unchanged, so existing ciphertext keeps decrypting.

The invariants matter more than the convenience, since a second key silently
orphans every field-encrypted secret:

- An existing key file is used, never replaced. Unreadable, empty, non-base64,
  too short or malformed fails startup; generation follows only from a proven
  absent file. Absence itself is proven — only ENOENT/ENOTDIR count, any other
  stat error refuses.
- An explicitly configured SCRYE_APP_SECRET_KEY_FILE pointing at a missing file
  refuses to start rather than substituting a generated key.
- A supplied secret alongside a previously generated key it does not cover
  (version-aware, since tokens name their key version) refuses to start.
- Concurrent starts cannot both generate: the O_EXCL loser reads the winner's
  key, and a key file caught empty mid-write is retried (only that case, via a
  dedicated MasterKeyFileEmptyError) instead of failing.
- A generated key that fails its permission check is removed by the call that
  created it, so the next start cannot adopt the file this one refused.

Compose and the README stack no longer require the secret; the Docker-secret
blocks are kept commented out for deployments that want the key and the data on
separate mounts. Docs cover where the key lives, that it is generated on first
run, that it must be backed up, what is lost if it isn't, and how this differs
from a passphrase backup bundle (which does not need the key).

See docs/ARCHIVE.md § Deviations (2026-07-29) for the full rationale, including
the amended CLAUDE.md master-key sourcing rule.

* fix(docker): preflight the data directory before migrations; make the ownership errors actionable

An unwritable /data surfaced only as `sqlite3.OperationalError: unable to open
database file` from `alembic upgrade head`, which names neither the path nor the
cause. That is the same first-run-blocker class as a missing master key — and the
one a bind-mounted NAS volume actually hits, since a bind mount keeps the host
directory's ownership while a named volume inherits the image's.

The entrypoint now checks that the database directory exists and is writable
before Alembic runs, and fails with the directory, the container uid:gid, a
literal `chown -R <uid>:<gid> <host path>`, the `user:`-matching alternative, and
the note that a named volume gets this right automatically. A dozen lines of sh:
a probe file created and removed, no validation framework.

Tracing the same scenario through key generation showed the ordinary NAS bind
mount is fine — on Linux a new file always belongs to the creating euid, so a
foreign-owned but writable directory yields a 0600 key owned by the app uid. The
owner check only fires where the filesystem synthesizes ownership (CIFS/SMB
`uid=`, NFS squashing), where 0600 protects nothing. Both master-key messages in
this class are now as actionable as the preflight: the unwritable-directory error
carries the same uid/chown guidance, and the synthesized-ownership error states
that chown cannot help and points at matching `user:` or a Docker secret.

Tests execute the shipped entrypoint with sh against real directory permissions,
stubbing alembic/uvicorn on PATH so "did the boot stop before migrations?" is
observable, and rewriting only `cd /app/backend` (a path that exists solely in
the image). Two cases skip under root, which bypasses permission bits; they run
in CI, which is non-root. Message content is asserted for both key errors too.

See docs/ARCHIVE.md § Deviations (2026-07-29) for the full trace table.
)

* feat(settings): show which master key is in force on the About tab

The auto-generated master key logs its back-this-up warning once, at generation.
An operator who deploys and returns six months later sees nothing — and a
per-boot warning would be worse, firing on every start of the documented default
until people learn to skip startup logs. The About tab is where an admin actually
looks, so the fact lives there.

One row through the existing core/system_info.py -> api/settings.py path:
master_key_info() reports the resolved source and path, AboutOut carries it, and
AboutPanel renders "Auto-generated at <path> - back this up; a Docker secret gives
stronger at-rest separation" or, for a supplied secret, "Supplied as a secret file
at <path> - keep your copy backed up". Accurate for both sources rather than
autogen wording shown unconditionally.

Constraints:

- No key material, and no key version either: source and path only, so this
  relocates an existing fact instead of disclosing a new one.
- Admin-only on an endpoint that is not. GET /settings/about is readable by every
  role, so the row is populated per request for admins and omitted otherwise --
  the path is deployment layout. Gated on effective_role, so an admin's
  role-capped API token sees what its role sees.
- Never the reason a page fails: master_key_info() reads the cached startup
  resolution, so it does no filesystem work and cannot generate a key from a
  request; an unresolvable key omits the row rather than erroring the response.

Tested through the API, matching how the other About rows are covered: both
sources, no key material and no extra fields in the payload, and omission for a
viewer, for a role-capped admin token, and when the resolution raises.

See docs/ARCHIVE.md § Deviations (2026-07-29) for why this channel over a log line.

* test(frontend): cover both master-key variants on the About tab

The master-key row is the only conditional logic on this tab, and inverting it is
not a cosmetic bug: it would tell a deployment that supplied a Docker secret --
the recommended posture -- that its key was auto-generated on the data volume, and
the reverse. That is wrong security guidance shown to the operators who did the
right thing, so each branch is pinned to text the other cannot produce.

Covers: the auto-generated source renders the auto-generated wording with the
at-rest separation advice and not the supplied-secret wording; the secret-file
source renders the supplied-secret wording without the separation advice; both
render their back-it-up guidance; and the row is absent entirely when the response
omits the field (a non-admin caller, or no resolvable key).

Verified against a deliberately inverted conditional before accepting it: all four
source-dependent cases fail, and the omission case correctly still passes since it
does not exercise the branch.
…g sign-in (#112)

Session cookies are Secure by default, and a browser discards a Secure cookie
set on an http:// page. Deploying over plain HTTP therefore produced repeated
401s with valid credentials, with nothing in the logs or the UI explaining why:
the login returned 200, the cookie never landed, and every request after it was
unauthenticated. The cookie posture is unchanged; the failure is now legible.

- Startup logs whether HTTPS enforcement is on and, when it is, that logins over
  plain HTTP will fail unless the operator opts out — naming
  SCRYE_SESSION_COOKIE_SECURE=false explicitly. Enforcement off warns that
  cookies now travel in cleartext. Wildcard, unparseable, and empty
  SCRYE_FORWARDED_ALLOW_IPS values each get their own warning.
- Every session-minting path (password login, first-admin setup, MFA verify,
  OIDC login start) refuses with 503 and a transport-specific message rather
  than issuing a session the browser will throw away. Setup is refused before
  the admin is created, so bootstrap stays re-runnable.
- The refusal logs at ERROR and states whether the submitted credentials were
  valid, so a valid-credential rejection never reads as a bad-password 401, and
  records a distinct auth.login_blocked_insecure_transport audit action. The
  client-visible refusal is identical for valid, invalid, and unknown accounts.
- The login and setup screens show a banner explaining this is an HTTPS
  configuration issue, not wrong credentials, driven by two transport-only
  fields on /auth/status so it never reflects credential state.
- X-Forwarded-Proto is honored so a TLS-terminating proxy can report the
  client's real scheme, trusted only from the peers already configured in
  SCRYE_FORWARDED_ALLOW_IPS and only ever upgrading http -> https. Secure is
  never dropped from an auto-detected scheme, which would silently downgrade
  every deployment behind such a proxy.

See docs/ARCHIVE.md § Deviations (2026-07-29) for the full rationale.
#113)

* docs: scope the OIDC identity auto-retrieval (account-linking) feature

Handoff document only — no code. Corrects the request's premise (Scrye has
no manual subject field; the gap is linking an existing account), surveys
per-provider subject semantics, specifies the reuse of the existing
authorization-code machinery with a link purpose, enumerates the abuse
cases and their prevention, details the L2/SEC-8 interaction and its
compensating fresh-auth gate, and recommends building at minimal scope.

Per the 2026-07-26 cleanup decision, the durable record goes to
docs/ARCHIVE.md §14 and this file is deleted once the work lands.

* docs: green-light the OIDC linking scope and cover IdP-side subject change

Marks the scoping green-lit at the minimal scope (self-link + guarded
self-unlink), cross-references the new tracking issue #114, and adds §7
on stale links: an IdP account recreated with a new sub, or an Authentik
subject-mode change re-keying every user, leaves a link row that still
reads as healthy while OIDC logins recreate the duplicate-account /
dead-end failure this feature exists to remove. Recommends fail-closed
detection at the login callback (distinct identity_stale error + audit
event, never an auto-rebind), last_login_at surfaced on the linked
status, and a re-link runbook — scoped into the §6 deliverables.
…the waiver blocks (#117)

CVE-2026-11940 and CVE-2026-11972 (both HIGH, both tarfile) began tripping the
dogfood gate on 2026-07-30 when they reached Grype's DB. CVE-2026-0864 is a
non-gating Medium in the same module, waived alongside them for a clean report.

Verified at the source per CLAUDE.md § Dependency hygiene, not from Grype's
FIXED IN column — which reports 3.15.0b4 for all three and does not record the
3.14 backports. All three fixes are merged to the 3.14 maintenance branch and
absent from released v3.14.6 (commit c63aec6, 2026-06-10), so all three close on
3.14.7:

  CVE-2026-11972  e86666c  gh-151981/#151992   2026-06-23  (+13d)
  CVE-2026-11940  79c06bd  gh-151558/GH-151559 2026-06-23  (+13d)
                  5e0ef3f  gh-151987/#152609   2026-06-29  (+19d)
  CVE-2026-0864   2cf26d0  gh-151497/GH-151979 2026-06-24  (+14d)

None is an ancestor of v3.14.6, confirmed with git merge-base --is-ancestor.
No 3.15 move is warranted and none should be scoped off the back of this.

Scrye does not use tarfile — the string appears nowhere in the repo and backup
bundles are a JSON envelope — so the residual risk is unreachable stdlib code
in the image, the same rationale the poplib acceptance rests on.

Tracked in a new issue rather than appended to #98, because #98 carries its own
dated verification scoped to its own trio. The blocks are now labelled A-1
(#98) / A-2 (#116) / B (#52) with an index at the top of the section, so the
block-to-issue mapping is legible from the file — a single shared "tracked in
#NN" header is what previously made two unrelated decisions look like one.

See docs/ARCHIVE.md § Deviations for the dated entry.
…d declarations (#115)

* chore(release): bump the app version to 0.2.0 and guard the duplicated declarations

The version is declared independently in backend/app/__init__.py,
backend/pyproject.toml and frontend/package.json, and nothing derives one
from another or from the git tag — publish.yml computes the image tag from
the pushed ref but never stamps a version into the image. Tagging v0.2.0
would therefore have published :0.2.0 running an app reporting 0.1.0 on the
About tab, /healthz, the OpenAPI document, backup bundles and the
scrye_build_info metric.

Bump all three (plus the lockfile's root fields) and the four docs/Compose
references to the example scrye:0.1.0 image tag and /healthz sample output.

Add backend/tests/test_version.py asserting pyproject, package.json and
package-lock.json agree with app.__version__, so a future release cannot
half-land. The About endpoint test asserted only that version was truthy; it
now asserts equality with __version__. AboutPanel gains a version-stat test
bound to its own card, since the Scanners table also has a Version header.

Historical 0.1.0 references in CHANGELOG.md and the backup-record fixtures
are release history and stay as they are; converting [Unreleased] to [0.2.0]
is a tag-time step per CONTRIBUTING.md § Releasing.

See docs/ARCHIVE.md § Deviations for the single-sourcing recommendation.

* docs(roadmap): fold image version-stamping into the single-sourcing item

publish.yml computes the image tag from the pushed ref but never stamps a
version into the image itself: there is no LABEL in docker/Dockerfile and no
labels:/build-args: in publish.yml, dev-nightly.yml or ci.yml — none of them
uses docker/metadata-action, which is what normally generates the OCI label
set. So `docker inspect` on a published image reveals nothing about what is
inside it.

This is metadata hygiene rather than a defect: the running app reports its
version correctly because app/__init__.py is baked in, and both /healthz and
the About tab serve it. It belongs beside the single-sourcing work because
both are the same problem — a version derived from nothing — seen from the
inside and the outside.

Records that the image is not wholly opaque today: publish.yml already
attaches BuildKit SLSA provenance and an SPDX SBOM plus a GitHub-signed
attestation, so the build is describable, just not through the one-command
channel operators reach for.

Docs only; no code or workflow change.
…#118)

* ci(grype): print waived matches with --show-suppressed

The gate printed a bare "No vulnerabilities found", which is ambiguous
between "nothing matched" and "everything that matched was waived" — Grype's
ignore rules drop matches from the report before it prints, so a clean-looking
line is exactly what a seven-entry waiver list also produces. Confirming a
waiver had taken effect meant diffing against a run from before it landed
rather than reading the log in front of you.

With the flag the waived matches print as (suppressed) rows next to the
verdict, so the log says which CVEs were let through and why the run is green.
Same legibility principle as the A-1/A-2/B block-to-issue index.

Display-only: Grype evaluates --fail-on against non-ignored matches, so
listing the ignored set cannot change pass/fail. Three of the seven waivers
are HIGH, so if --show-suppressed did fold them back into --fail-on high, this
change's own run would fail rather than pass.

Touches only the gate step. ci/grype.yaml's entries, ci/trivyignore and both
informational scans are unchanged.

See docs/ARCHIVE.md § Deviations for the dated entry.

* ci(grype): keep the gate's verdict and list its waivers from one scan

Replaces the --show-suppressed approach, which was tried and rejected on the
evidence from its own CI run.

--only-fixed is itself implemented as an ignore rule, so its matches share the
ignoredMatches bucket with the by-ID waivers. --show-suppressed therefore
printed every won't-fix package too — ~250 rows against the 7 wanted — and,
having rows to print, Grype emitted the table instead of the "No
vulnerabilities found" line. That removed the verdict and buried the waivers:
a worse legibility problem than the one being fixed.

Instead the gate now takes two outputs from a single invocation
(-o table -o json=), keeping its clean verdict on stdout and writing the
machine-readable report. A new non-gating step jq-filters ignoredMatches for
entries whose appliedIgnoreRules carry a `vulnerability` key, which selects the
deliberate ci/grype.yaml waivers and drops both the --only-fixed set and the
location-based bundled-binary rules. It is labelled as exceptions rather than
findings and runs if: always(), so the list still prints when the gate fails.

One invocation, not two: a second grype run would repeat the vuln-DB load and
the full image scan, and this job is already the slowest in CI.

The gate's flags are unchanged and the new step is non-gating, so pass/fail
behaviour is identical.

See docs/ARCHIVE.md § Deviations for the dated entry.

* docs(roadmap): track the test_cancel_queued_scan timing flake

test_cancel_queued_scan is timing-dependent by construction: a monkeypatched
scanner sleeps 0.2s to hold the only worker slot while a second scan must
still be `queued` when the cancel POST lands. On a loaded runner that window
closes, the scan has left `queued`, and the endpoint correctly returns 409 —
queued-only cancellation is a deliberate decision (ARCHIVE §14, 2026-07-03).
The test is wrong, not the code.

Records that the fix is to make the worker's slot acquisition observable to
the test, not to widen the sleep, which would only lengthen the odds while
leaving the race in place and slowing every run.

Also records why it is worth fixing rather than tolerating: a test that
reddens CI intermittently trains people to re-run without reading, which is
how a real failure gets waved through.

Docs only; the test itself is unchanged here.
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.15 to 1.1.16
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](juliangruber/brace-expansion@v1.1.15...v1.1.16)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.16
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…bump, Dependabot security-PR routing (#121)

Cuts CHANGELOG `[Unreleased]` to `[0.2.0] - 2026-07-31` with a fresh empty
`[Unreleased]` above it and a `[0.2.0]` compare link, and adds a `### Security`
entry covering the base-image and dependency currency that answers issue #75.

Bumps brace-expansion 1.1.16 -> 1.1.18 and the nested
@typescript-eslint/typescript-estree copy 2.1.3 -> 2.1.4, regenerated with npm
rather than hand-edited. Dependabot's #120 proposed this but opened against
main, and retargeting left its diff stale, so it was closed in favour of doing
this on dev.

Documents in CLAUDE.md and CONTRIBUTING.md that Dependabot security updates
always open against the default branch, ignoring target-branch, and that
retargeting such a PR does not work.

See docs/ARCHIVE.md § Deviations (2026-07-31) for the full pass.
Reconciles #110 (brace-expansion 1.1.15 -> 1.1.16), which was merged directly
into `main` as a Dependabot security update, ahead of the v0.2.0 promotion.
`main` was not an ancestor of `dev` — #119 replicated #110's change on `dev`
rather than merging `main` — so both branches had independently edited
frontend/package-lock.json and the promotion PR would not merge.

frontend/package-lock.json resolved in favour of `dev`. Verified this cannot
resolve away from the security fix: no package is present on `main` and absent
from `dev`, and the only version differences are the app version (0.1.0 ->
0.2.0) and brace-expansion 1.1.16 -> 1.1.18 / 2.1.3 -> 2.1.4, both of which
move forward past what `main` carries.

# Conflicts:
#	frontend/package-lock.json
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.
The image-tag table and the `docker pull` block both used `:1.4.0` as the
"pin a release" example, for a project whose only release is v0.1.0. Corrected
to `:0.2.0`. It sits in the Quick start path, on the page a reader lands on the
day a release goes out.
Squashing a promotion replaces dev's commits with one new commit on main that
has no ancestry link to them, so main and dev diverge the instant it lands and
every subsequent back-merge becomes a conflict-resolution exercise against a
squashed copy of work dev already has.

That is not theoretical here. Because main was not an ancestor of dev, #110 (a
Dependabot security update, which lands on main) could not be back-merged
cheaply, so #119 replicated its change on dev instead of merging main. Both
branches had then independently edited frontend/package-lock.json and the
v0.2.0 promotion PR opened dirty — requiring a hand-picked side on a file where
picking wrong silently reverts a security fix.

A merge commit removes the cause: main keeps the individual commits a release
should preserve, dev stays an ancestor of main, and the back-merge becomes a
fast-forward. Feature and contribution PRs into dev are unchanged and still
squash-merged; the rule is asymmetric on purpose.

Corrects the three passages that assumed squashing: the back-merge guidance in
both documents (a promotion's back-merge now fast-forwards; the resolve-in-
favour-of-dev rule stays for commits that landed on main independently, with a
new check that dev is genuinely ahead first), and the git-identity note, which
described squash-merge authorship but applies equally to a merge commit.

Also logs the release-prep follow-ups in §14: the CHANGELOG backfill, the
README tag-example fix, and tracking issues for three dependency findings —
#123 (react-router GHSA-qwww-vcr4-c8h2, runtime dep but not reachable outside
RSC mode), #124 (postcss, dev-only, patch-level fix available) and #125
(brace-expansion GHSA-mh99-v99m-4gvg, no fix on the 1.x or 2.x lines, so the
1.1.18 bump clears nothing).
@tyler-rich
tyler-rich merged commit bb354a5 into main Aug 1, 2026
9 of 10 checks passed
@tyler-rich
tyler-rich deleted the dev branch August 1, 2026 23:56
@tyler-rich
tyler-rich restored the dev branch August 1, 2026 23:56
tyler-rich added a commit that referenced this pull request Aug 2, 2026
* backend: bump fastapi 0.140.0 -> 0.140.13 and regenerate requirements.lock

Reapplies the bump Dependabot proposed in #127, which could not merge as
opened: Dependabot edits pyproject.toml without touching
backend/requirements.lock, so CI's drift gate rejects it. The lock is
regenerated with the pinned command from CONTRIBUTING.md § Backend
dependency lock (uv 0.8.17, --group build --generate-hashes
--python-version 3.14) and moves three lines: fastapi's version and its
two wheel hashes. starlette stays at the explicitly-pinned 1.3.1.

All thirteen patch releases are internal dependency-tree/OpenAPI
refactors (0.140.1-0.140.7) or fixes to paths this backend does not use
- SSE/JSONL streaming, response_model_* on Iterable returns,
jsonable_encoder's exclude_defaults, and nested-Annotated sequence
params. Verified by grep: no jsonable_encoder, no StreamingResponse, no
SSE helper, no response_model_* anywhere in backend/app.

Confirmed against the regenerated lock rather than the version pin: a
venv on CPython 3.14.6 built the way the image builds (pip install
--require-hashes -r requirements.lock, then pip install --no-deps
--no-build-isolation .) runs the full suite green - 666 passed, 5
skipped, identical to the pre-bump baseline.

* ci: bump docker/login-action 4.5.1 -> 4.5.2

Reapplies what Dependabot proposed in #128, SHA-pinned per the repo's
Actions convention with the tag as a trailing comment.

The commit SHA was resolved from upstream with git ls-remote --tags
against docker/login-action rather than read off the bump description -
refs/tags/v4.5.2 maps to 371161bbe7024a29a25c5e19bfcbc0804fe9ad2c.
Trusting a rendered SHA is the substitution a SHA pin exists to prevent.

v4.5.2 contains one substantive commit, "surface Docker Hub OIDC error
responses" (docker/login-action#1058): it improves the error text when a
Docker Hub OIDC login fails. All three call sites here log in to ghcr.io
with the built-in GITHUB_TOKEN as username/password - not Docker Hub,
not OIDC - so the changed path is never entered. No input, output, or
breaking changes. This matters because these workflows run only on the
tag-gated publish path and the nightly, which CI cannot exercise.

* ci(dependabot): ignore node majors and the local scrye build tag

Two ignore entries, both closing a recurring failure.

node majors, on the docker entry. #126 proposed node
22-bookworm-slim -> 25-bookworm-slim. Node's odd-numbered lines never
become LTS: v25 reached end-of-life on 2026-06-01, before that PR was
opened and years before the 22 line's 2027-04-30, so it would have moved
the frontend builder onto an unsupported runtime. The wanted move is
22 -> 24 (Active LTS, 2028-04-30) and is tracked in docs/ROADMAP.md
because it spans the Dockerfile, ci.yml and CONTRIBUTING.md together.

The ignore is scoped to version-update:semver-major so digest refreshes
of the pinned 22 tag still come through - declining a major previously
left Dependabot offering nothing for this image and the digest went
stale until #107 refreshed it by hand.

dependency-name: scrye, on both docker entries. docker/docker-compose.yml
pins image: scrye:0.2.0, Scrye's own locally-built tag; Dependabot
resolves the unqualified name as Docker Hub's library/scrye, gets a 401,
and fails the run with private_source_authentication_failure. Reading the
files confirms the compose file carries the only such reference and
docker/Dockerfile carries none, so the substantive entry is on the
docker-compose ecosystem; it is repeated on the docker entry because the
failing run's ecosystem is visible only in the Dependabot UI, and
suppressing a name the Dockerfile never mentions costs nothing.

The image tag itself is unchanged: qualifying it as
ghcr.io/tyler-rich/scrye:0.2.0 would make the compose file pull a
published image instead of building locally, changing what the
documented quick start does.

* docs: record the post-v0.2.0 dependency cleanup and the base-branch diagnosis

A version update sitting on main is not the documented security-update
case. #126, #127 and #128 were all opened against dev correctly - their
head branches carry the /dev/ target-branch segment - and each records an
automatic_base_change_succeeded event 2-3 seconds after the v0.2.0
promotion (#122) merged. GitHub retargets open PRs whose base branch is
deleted to the merged PR's base, and auto-delete-on-merge deleted dev as
the promotion's head branch. dependabot.yml was not involved, and neither
was grouped security updates: all three are version updates across three
different ecosystems. The remedy is already in place (auto-delete
disabled on 2026-08-02); target-branch and the grouping setting stay as
they are.

CLAUDE.md § Dependency hygiene and a new CONTRIBUTING.md subsection carry
the rule and a two-signal table for telling the two causes apart.

docs/ROADMAP.md: the Node 22 -> 24 item rewritten to state that it must
be its own PR with a registry-verified digest pin, and to name the three
files that have to move together - bumping the Dockerfile alone would
leave CI on 22 while the image builds on 24. New item for enabling
GitHub code scanning (CodeQL) on Python and TypeScript: free for public
repos, default setup adds a workflow running on every push and PR, and
the first run surfaces a triage backlog, so it wants its own session.

docs/ARCHIVE.md gains the dated §14 entry covering all of it, including
the correction to #125: GHSA-mh99-v99m-4gvg was re-scoped per major on
2026-07-31 and 1.1.18 / 2.1.4 are both above their line's first patched
version, so the #121 bump did clear it. Verified at the source - 1.1.17
and 2.1.3 introduce EXPANSION_MAX_LENGTH and name CVE-2026-14257 in
their comments - not from the advisory metadata that produced the wrong
claim in the first place. #125 closed on its own stated criterion; #123
and #124 re-verified and still accurate.

* docs: distinguish base_ref_changed from automatic_base_change_succeeded

The retarget diagnosis said #110 and #120 have "no such event", which is
true of automatic_base_change_succeeded but reads as though their
timelines are bare. #120 carries two ordinary base_ref_changed events -
the manual retarget-to-dev-and-back already documented on 2026-07-31 -
so a reader checking a timeline needs the event types told apart, not
just the presence of a base change.

* docs: state the evidence for the node digest-refresh assumption

The ignore is scoped to version-update:semver-major so digest refreshes
of the pinned 22 tag keep arriving, but that rests on documented
update-types semantics plus reported updater behaviour - not on an
observed run in this repository. Says so, names the first scheduled
docker run as the test, and records the fallback if it is wrong (refresh
the digest by hand, as #107 did).

* ci: pin docker/login-action to v4.6.0 rather than v4.5.2

Supersedes the 4.5.2 applied earlier in this branch. 4.5.2's only
substantive commit improves Docker Hub OIDC error text, a path none of
the three call sites can reach - all three log in to ghcr.io with the
built-in GITHUB_TOKEN as username/password. 4.6.0 lands a day later and
its change is at least adjacent to what this repo does: it hardens the
buildx-scoped config path used by the login -> buildx -> push chain, and
carries the action's own bundled dependency bumps.

SHA resolved from upstream with git ls-remote --tags, not from a
changelog: refs/tags/v4.6.0 -> dbcb813823bdd20940b903addbd779551569679f.
The moving v4 tag currently points at the same commit; the pin is the
immutable v4.6.0 commit, not the alias.

Read at the source by diffing v4.5.2...v4.6.0 rather than from the
release notes, because these workflows run only on the tag-gated publish
path and the nightly, which CI cannot exercise. action.yml is
byte-identical, src/main.ts and src/docker.ts are unchanged, and the
whole change is in src/context.ts's buildx scoped-config-dir helper -
path.resolve containment on the registry and scope inputs behind a new
isChildPath() helper.

Behaviourally a no-op here: that helper returns early on
'if (scopeDisabled() || !scope || scope === "")', and no call site
passes a scope input. Currency plus defence-in-depth, not a fix for
anything reachable.

* frontend: bump postcss 8.5.16 -> 8.5.25 (GHSA-r28c-9q8g-f849)

Applied by hand rather than waiting for Dependabot: it has not proposed
this, and after closing #126/#127/#128 there are no open Dependabot PRs
at all, so a HIGH advisory was waiting on a bot that was not going to
act.

Which version actually clears it was verified in the published source,
not taken from the advisory range - GHSA-mh99-v99m-4gvg was re-scoped
mid-flight and #125 was wrong because of it. postcss 8.5.18's
lib/previous-map.js loadFile() gains the containment check the advisory
describes (relative(dirname(cssFile), path) rejected when it is '..',
starts with '../', or is absolute); 8.5.17 has none of it. So 8.5.18 is
the real floor. The same check is still present in 8.5.25, which is what
is pinned - current release on the pinned 8.5 line, per CLAUDE.md
§ Dependency hygiene.

No overrides entry needed and no parent bumped: postcss is a direct
devDependency here, and every package that also reaches it declares a
peer/caret range 8.5.25 satisfies (vite's ^8.5.3 included), so raising
the single pin lifts the tree and npm ls shows one deduped copy.

Regenerated with npm pkg set + npm install --package-lock-only, not by
editing version strings, so resolved URLs and integrity hashes moved
with the version. Diff is postcss 8.5.16 -> 8.5.25, its nanoid floor
^3.3.12 -> ^3.3.16, and nanoid 3.3.15 -> 3.3.16 - all dev-only, no new
packages.

npm ci installs 8.5.25 with the containment check present; ESLint,
Prettier, the 20-file/69-test Vitest suite and npm run build all pass;
npm audit no longer reports postcss. Nothing ships either way - postcss
runs during vite build and the image copies only dist/.

* docs: record the 4.6.0 pin and the postcss bump in the dated entry

The §14 entry's login-action item now covers 4.6.0 and why it was taken
over the 4.5.2 that #128 proposed, including the source-level diff and
the honest note that the hardened path is gated on a scope input this
repo never passes.

The advisory-issues item gains the full postcss treatment: why it was
applied by hand, the 8.5.18 fix floor verified in the published source
rather than read off the advisory range, why no overrides entry or
parent bump was needed, and the post-bump verification. #124 closed on
its own stated criterion, the same standard applied to #125.

CHANGELOG [Unreleased] gains a Security entry for the postcss advisory -
and says explicitly that unlike the brace-expansion bump in 0.2.0, this
one does clear its advisory - plus Changed entries for the login-action
and fastapi pins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Evaluate migrating the Docker socket proxy from tecnativa to wollomatic/socket-proxy

1 participant