Skip to content

Security + supply-chain review remediation (H11, M2–M5, M22–M26, L1–L4, L23) - #64

Merged
tyler-rich merged 18 commits into
devfrom
claude/security-review-findings-76qnmu
Jul 13, 2026
Merged

Security + supply-chain review remediation (H11, M2–M5, M22–M26, L1–L4, L23)#64
tyler-rich merged 18 commits into
devfrom
claude/security-review-findings-76qnmu

Conversation

@tyler-rich

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

Copy link
Copy Markdown
Owner

Works the security-review (SEC-*) and supply-chain-review (SC-*) findings from docs/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/black clean, .env.example and requirements.lock sync checks pass, frontend untouched.

Stop-and-ask decisions (confirmed with maintainer up front)

Findings

# Fix
H11 / SC-1 Hash-pinned backend/requirements.lock; image installs --require-hashes then --no-deps .; CI lock-sync check. Verified installable under Python 3.13.
M2 / SEC-3 Master key must be base64 decoding to ≥32 bytes; raw-passphrase fallback rejected unless the temporary SCRYE_ALLOW_WEAK_MASTER_KEY opt-out (warns). Input validation only.
M5 / SEC-6 New core/egress.py SSRF guard for notification/registry/docker-proxy fetchers: loopback + link-local/metadata always refused; private refused unless new SCRYE_ALLOW_INTERNAL_EGRESS (default off); docker-proxy allows private but not loopback/metadata.
M3 / SEC-4 Log-redaction consumes whole unquoted spaced/comma secrets (tempered-greedy, non-space anchored).
M22 / SC-6 Refresh stale node:22-bookworm-slim digest.
M23 / SC-7 docker-socket-proxy 0.3.0 → v0.4.2 (digest-pinned). See caveat below.
M24 / SC-4 Publish workflows attach SLSA provenance (mode=max) + SPDX SBOM + GitHub-signed attest-build-provenance (job-level id-token/attestations).
M25 / SC-5 New weekly rescan.yml re-scans published :latest/:dev with the same Trivy/Grype gate; opens/comments a tracking issue on a finding.
M26 / SC-8 Dockerfile cosign-verifies each scanner checksums.txt (keyless, identity pinned to the upstream release workflow) before sha256sum -c; cosign pinned by digest. See caveat below.
L1 / SEC-7 Field-encryption AAD optionally binds to the row id; backward-compatible + migration-free (decrypt falls back to the column tag; lazy upgrade on next write). Backup re-wrap preserves each value's binding.
L2 / SEC-8 OIDC logins under a mandatory policy record mfa_delegated_to_idp (audit visibility; no auth-behavior change — review recommended none).
L3 / SEC-9 Policy-forced first-enrollment records forced_by_policy (detection for the enroll-on-first-login window).
L4 / SEC-10 Rate limiter evicts idle keys once the map is large (amortized); PendingMfaStore caps challenges per user.
L23 / SC-9 Digest-pin the # syntax=docker/dockerfile:1.7 frontend.

CI-validated / re-verify caveats (egress-restricted prep environment)

The Docker Hub CDN and api.github.com for non-scoped repos were blocked here, so two items are validated by CI rather than locally:

  • M26 — the exact upstream signature asset names / cosign certificate identities are exercised by the CI image-build job (which has full GitHub network). If that job goes red on the scanners stage, the identity regexp / asset names are the thing to adjust.
  • M23 — image pull/boot couldn't run; re-verify the hardened docker-env profile boots (read-only FS + /run tmpfs + cap_drop: ALL) on a Docker-Hub-reachable host. Env-var API and the GET /images/json client path are unchanged across 0.3 → 0.4.

Contract/behavior notes: new operational knobs SCRYE_ALLOW_INTERNAL_EGRESS (in .env.example) and SCRYE_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 in docs/ARCHIVE.md § Deviations.

…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
tyler-rich merged commit caf0c74 into dev Jul 13, 2026
4 checks 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).
@tyler-rich
tyler-rich deleted the claude/security-review-findings-76qnmu branch July 31, 2026 04:59
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.

1 participant