Security + supply-chain review remediation (H11, M2–M5, M22–M26, L1–L4, L23) - #64
Merged
Merged
Conversation
…mage (SC-1) pyproject.toml pins only direct runtime dependencies, so every image build previously resolved the transitive closure (anyio, certifi, h11, cffi, Mako, etc.) fresh from PyPI, unpinned and unverified — two builds of one commit could differ, and a newly compromised transitive release would be picked up silently. Add backend/requirements.lock: the fully-resolved, hash-pinned transitive set compiled from pyproject.toml with `uv pip compile --generate-hashes` (uv is a build/dev-time tool only; it is not added to the runtime image and pyproject stays standard PEP 621). The image now installs runtime deps with `pip install --require-hashes -r requirements.lock` before copying app code (better layer caching), then the app package itself with `pip install --no-deps .` so the resolver can't reach back to PyPI for an unpinned copy. CI regenerates the lock with the pinned uv and the identical compile flags and fails on drift; a Dockerfile guard test pins the --require-hashes install so a future edit can't silently drop hash verification. CONTRIBUTING documents the regeneration workflow. Verified: the lock installs and hash-verifies all runtime deps under Python 3.13, and the app package builds and imports with --no-deps.
The only strength gate on the master key was length: a memorable passphrase was accepted as long as it was >= 32 bytes, and the field key is then derived with a single fast HKDF expand. If an attacker obtained scrye.db (the exact DB-read threat the field encryption defends against) and the operator had chosen a low-entropy but long key, it could be brute-forced offline at HKDF speed. Add an entropy floor at key load: the master key material must be valid base64 decoding to >= 32 bytes — the documented `openssl rand -base64 48` form. Raw passphrase material (anything not valid base64) is rejected with an actionable error instead of silently accepted via the UTF-8 fallback. A temporary boot-and-rotate escape hatch, SCRYE_ALLOW_WEAK_MASTER_KEY, lets an existing passphrase-keyed deployment start long enough to rotate; it logs a warning on every load so it can't quietly become permanent. This is input validation only. Key derivation and the on-disk token format are unchanged, so every existing encrypted value still decrypts (regression test added). README security model documents the requirement and the escape hatch.
The notification transports (webhook/Discord/Matrix/SMTP), the registry connectivity probe, and the Docker socket-proxy client all took an admin-supplied URL/host and connected with no destination check, so they could be pointed at the cloud metadata endpoint (169.254.169.254), loopback, or an internal service — an SSRF surface, admin-gated but unguarded. Add core/egress.py: resolve the target host and refuse loopback, link-local (including cloud metadata), multicast, unspecified, and reserved addresses always, and RFC-1918/ULA/CGNAT private addresses unless the operator opts in with SCRYE_ALLOW_INTERNAL_EGRESS (self-hosted deployments legitimately run an internal SMTP relay or private registry). The Docker proxy targets an internal sidecar by design, so it allows private addresses but still refuses loopback/metadata. The registry bearer-token realm — attacker-influenced, from the probed registry's own header — is screened too. Resolution here is best-effort (httpx re-resolves on connect), which is appropriate for an admin-gated, CSRF-protected surface. Settings gains allow_internal_egress (off by default); README security model and the env-var table document it.
… token (SEC-4) The key/value redaction filter's unquoted-value branch stopped at the first whitespace, comma, semicolon, or ampersand, so an unquoted secret containing any of those — an SMTP password, a backup passphrase, a multi-word token — was only prefix-masked and its tail leaked to the log (`password=p@ss w0rd here` left `w0rd here`; `api_key=abc,def` left `,def`). Make the unquoted match tempered-greedy: consume to end of line unless it first reaches the start of another `key=`/`key:` pair, so a spaced/comma-bearing secret is redacted whole while a following structured field (`region=us`) is still preserved. The value is anchored to a non-space first character so the engine can't backtrack the key's trailing space and start the value on it, slipping past the Bearer/Basic guard. Redaction stays bounded to a single log line. Over-redacting a trailing free-text phrase is the accepted trade-off for never leaking secret bytes; two tests that asserted trailing context survived a single-token secret are updated, and a spaced-secret regression test is added.
…gest (SC-6) The frontend builder pinned a node:22-bookworm-slim digest that upstream had superseded by ~a Debian patch cycle, so the build ran on an older OS snapshot. Bump to the current manifest-list digest (sha256:53ada149d435c38b14476cb57e4a7da73c15595aba79bd6971b547ceb6d018bf). Tag + digest are kept together (human-readable and immutable). Builder stage only — nothing from it ships to the runtime image except the compiled dist/.
The read-only Docker socket proxy — the single most security-sensitive sidecar, the only container mounting docker.sock — was pinned to 0.3.0 (~15 months old), running a year-plus-old HAProxy base. Bump to v0.4.2 (current stable; upstream now prefixes tags with `v`) with its resolved manifest digest. The env-var interface (IMAGES/CONTAINERS/INFO/POST) is unchanged across the 0.3 -> 0.4 line and the app only calls GET /images/json (gated by IMAGES=1), so no client change is needed. The existing hardened-boot NOTE (read-only FS + /run tmpfs + cap_drop:ALL; add SETUID/SETGID only if privilege-drop fails) still applies and should be re-verified on a Docker-Hub-reachable host — image pull/boot could not be exercised in the egress-restricted environment where this was prepared. A future migration to wollomatic/socket-proxy is tracked separately (#63).
Published images carried no provenance or SBOM attestation, so a consumer of ghcr.io/tyler-rich/scrye couldn't verify that :latest/:<version>/:dev was built by this repo's workflow from a given commit — a credibility gap for a tool whose product is SBOM/vulnerability transparency. The shared build-image action gains provenance/sbom inputs (default off, so the CI build-only check is unaffected) and exposes the pushed image digest. The two publish workflows turn on BuildKit SLSA provenance (mode=max) + SPDX SBOM on the image manifest, and add a GitHub-signed build-provenance attestation via actions/attest-build-provenance (SHA-pinned), verifiable with `gh attestation verify oci://ghcr.io/tyler-rich/scrye:<tag>`. The id-token/ attestations scopes are granted at job level only, on the publish workflows. CONTRIBUTING documents verification.
The dogfood gate runs only on PRs/pushes, so a CVE disclosed after an image was published went unnoticed until the next unrelated build — during any quiet period the shipped :latest/:dev could carry a fixable HIGH/CRITICAL with nobody looking. Add rescan.yml: weekly (and on demand) it pulls each published image and re-runs the SAME Trivy + Grype gate the dogfood self-scan uses (fixable HIGH/CRITICAL, bundled trivy/grype/syft binaries excluded, same triage ignorefiles). It doesn't gate a merge — on a finding it opens, or comments on, a per-tag tracking issue (deduped by title). A leg whose tag isn't published yet skips cleanly. This also automates the bundled-binary CVE treadmill check the archive did by hand. README documents it.
… (SC-8) Each scanner tarball was verified against a checksums.txt downloaded from the same GitHub release — which defeats transit corruption but not a compromised release, since an attacker who can replace the tarball can regenerate the matching checksum beside it. Add keyless cosign verification of each publisher's signature over its checksums.txt before the existing sha256sum check: cosign is pinned by digest from the official Sigstore ko image (COPY --from), and each verify-blob pins the GitHub Actions OIDC issuer and an identity regexp scoped to the upstream repo's release workflow (aquasecurity/trivy, anchore/grype, anchore/syft). This raises the guarantee from "matches what GitHub serves" to "signed by the upstream project's own release pipeline" (Fulcio identity + Rekor inclusion). The download → cosign-verify → sha256sum → extract order and the concurrent-subshell build-performance shape are preserved; a bad signature fails the build. Note: the upstream signature asset names and certificate identities could not be exercised in the egress-restricted prep environment (the release CDN is blocked); the CI image-build job validates them end-to-end.
…mn (SEC-7) Each stored secret's AAD was the (table, column) tag, so a ciphertext could be relocated between two rows of the same column and still authenticate (swap one registry's secret into another, or one user's MFA seed into another user's row). The threat needs DB write access — outside the DB-read model §6 defends — but is cheap to close. encrypt_secret/decrypt_secret now accept an optional row_id: when given, the AAD becomes "<table>.<column>:<row-id>". decrypt tries the row-bound tag first and falls back to the bare column tag, so this needs NO migration — every secret written before row binding still decrypts, and each upgrades to row binding the next time it's written. Call sites thread the row id (MFA and the OIDC singleton bind immediately since their rows pre-exist; API-resource create paths bind on first update, since the id doesn't exist pre-flush). The backup re-wrap preserves each value's existing binding across a build/restore cycle rather than silently upgrading it. Passing row_id=obj.id is None-safe (None -> column-only), so no create-flow reordering was needed. Tests: row-bound round-trip, a row-42 blob refuses to decrypt as row 43, and a legacy column-only blob still decrypts when the reader passes a row_id.
The mandatory-MFA policy (required_all / required_admin) is enforced only on local password login; the OIDC callback delegates the second factor to the IdP. That is an inherent, documented limitation — OIDC accounts have no local TOTP to challenge, and forcing one in the redirect flow (or blocking the login) can be wrong when the IdP already performed MFA, so the review recommends no behavioral change and enforcement at the IdP. Rather than change auth behavior, add operator visibility: when a mandatory policy would require a second factor for the user's role, the OIDC login records `mfa_delegated_to_idp` in the audit log, so an operator running mandatory MFA can see which logins relied on the IdP and confirm it enforces MFA. Auth behavior is unchanged; README security model documents the delegation and the audit signal.
When a mandatory-MFA policy applies to an account that has never enrolled, enroll-on-first-login lets whoever holds the password complete the first-factor setup — so in the window before the legitimate user enrolls, a password-only attacker could bind their own authenticator. This is inherent to self-service enrollment; the durable fix is out-of-band/admin-provisioned enrollment (a feature), and blocking self-enrollment would break mandatory MFA for everyone. Short of that, make the window auditable: the policy-forced first-enrollment completion (the one code path reached only from a password login's forced-enroll challenge) now records `forced_by_policy` on its auth.mfa_enabled event, so an admin can detect an unexpected enrollment and respond. README security model documents the window and the mitigation.
…-10) The sliding-window limiter pruned each key's event deque on access but never evicted idle keys, so a stream of distinct real client IPs grew the backing dict without bound; PendingMfaStore likewise had no per-user cap on concurrent challenges (bounded only by the shared IP limiter and the 300s TTL). The limiter now sweeps fully-expired keys once the map grows past a threshold, amortized to at most once per N events so it stays O(1) per call; the sweep runs after the in-flight key's window is finalized so the current key is never mistaken for idle. PendingMfaStore caps concurrent challenges per user, dropping the oldest when a new one would exceed the cap (other users unaffected). Tests cover idle-key eviction, active-key survival, and the per-user cap.
`# syntax=docker/dockerfile:1.7` resolved the BuildKit frontend by mutable tag at build time — the one build component not pinned by digest. Pin it to the current 1.7 manifest digest (keeping tag + digest together), consistent with the base images and scanner binaries. A guard test asserts the syntax directive stays digest-pinned.
Records the H11/M2–M5/M22–M26/L1–L4/L23 remediation batch (per-finding), the stop-and-ask decisions (uv lockfile, entropy-floor crypto, tecnativa bump), the new operational knobs (SCRYE_ALLOW_INTERNAL_EGRESS, SCRYE_ALLOW_WEAK_MASTER_KEY), the additive at-rest hardening (row-bound AAD, no format change), and the CI-validated items (M26 cosign asset names/identities).
Follow-up isort fix for the import introduced in the SEC-8 commit; no behavior change.
The initial SC-8 cosign step assumed all three scanners publish detached certificate (.pem) + signature (.sig) files, which broke the CI image build: Aqua/Trivy signs each artifact with a Sigstore protobuf bundle (`trivy_<ver>_checksums.txt.sigstore.json`), not a .pem/.sig pair, so the `.sig`/`.pem` downloads 404'd. fetch_verify_extract now takes a signature style: Anchore (grype/syft) keeps the `--certificate <.pem> --signature <.sig>` path (those assets are confirmed present), and Aqua (trivy) verifies the bundle with `cosign verify-blob --bundle <checksums.txt.sigstore.json> --new-bundle-format`. cosign is bumped to v2.6.1, which unambiguously supports `--new-bundle-format` (Trivy documents that cosign v2 requires that flag for the bundle format). Verified the RUN block's shell parses (`sh -n`); the real end-to-end verification runs in CI, which can reach the release assets.
CI showed the per-vendor split works — Trivy's Sigstore bundle verified OK — but grype/syft failed identity verification: Anchore signs its release checksums from `release.yaml@refs/heads/main` (the main-branch ref), while the regexp required a `@refs/tags/v…` ref. Broaden the Anchore identity regexp to accept either a heads or tags ref (still pinned to the anchore/grype and anchore/syft repos and the GitHub Actions OIDC issuer). Trivy's tag-ref regexp is unchanged (it passed).
tyler-rich
added a commit
that referenced
this pull request
Jul 26, 2026
…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).
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Works the security-review (
SEC-*) and supply-chain-review (SC-*) findings fromdocs/reviews/00-summary.md, in order, one commit per finding, minimal diffs, a test for each behavioral fix. Full backend suite green (550 passed, 3 skipped),ruff/blackclean,.env.exampleandrequirements.locksync checks pass, frontend untouched.Stop-and-ask decisions (confirmed with maintainer up front)
uvat build/dev-time only (compiles the hashed lock; not added to the runtime image; pyproject stays PEP 621).Findings
backend/requirements.lock; image installs--require-hashesthen--no-deps .; CI lock-sync check. Verified installable under Python 3.13.SCRYE_ALLOW_WEAK_MASTER_KEYopt-out (warns). Input validation only.core/egress.pySSRF guard for notification/registry/docker-proxy fetchers: loopback + link-local/metadata always refused; private refused unless newSCRYE_ALLOW_INTERNAL_EGRESS(default off); docker-proxy allows private but not loopback/metadata.node:22-bookworm-slimdigest.docker-socket-proxy0.3.0 → v0.4.2 (digest-pinned). See caveat below.mode=max) + SPDX SBOM + GitHub-signedattest-build-provenance(job-levelid-token/attestations).rescan.ymlre-scans published:latest/:devwith the same Trivy/Grype gate; opens/comments a tracking issue on a finding.checksums.txt(keyless, identity pinned to the upstream release workflow) beforesha256sum -c; cosign pinned by digest. See caveat below.mfa_delegated_to_idp(audit visibility; no auth-behavior change — review recommended none).forced_by_policy(detection for the enroll-on-first-login window).PendingMfaStorecaps challenges per user.# syntax=docker/dockerfile:1.7frontend.CI-validated / re-verify caveats (egress-restricted prep environment)
The Docker Hub CDN and
api.github.comfor non-scoped repos were blocked here, so two items are validated by CI rather than locally:docker-envprofile boots (read-only FS +/runtmpfs +cap_drop: ALL) on a Docker-Hub-reachable host. Env-var API and theGET /images/jsonclient path are unchanged across 0.3 → 0.4.Contract/behavior notes: new operational knobs
SCRYE_ALLOW_INTERNAL_EGRESS(in.env.example) andSCRYE_ALLOW_WEAK_MASTER_KEY(deliberately undocumented in.env.example); M3 over-redacts trailing free text after an unquoted single-token secret (accepted trade-off). Deviations logged indocs/ARCHIVE.md § Deviations.