security(alerts): preserve and surface an unusable webhook signing secret (SEC-WHSIGN-1) - #1222
Conversation
…cret Security regression review of the 2026-08-24 window (baseline daf66d9). One finding, SEC-WHSIGN-1, from a feature interaction rather than a single bad line. This window added alert_webhooks.json to the backup surface. Webhook HMAC signing secrets in that file are AES-GCM ciphertext (RISK-003) under a node-local key (.alert_webhook_key) that is deliberately never archived — the same rule that excludes .kek files. So restoring a backup onto a fresh volume, a supported operator workflow, is now a routine way to hold a secret that cannot be unwrapped, and the store's long-standing response to that was to blank the secret and keep delivering UNSIGNED: 1. deliverAttempt sets X-Culvert-Signature only when Secret != "", so every delivery goes out unsigned. A receiver that verifies the HMAC silently stops accepting this node's security alerts — quiet looks like healthy. 2. List() redacts Secret, so "no secret was ever configured" and "the secret is unusable" serialised identically. The only signal was one log line at boot, and the alerting plane cannot page about its own signing. 3. save() rewrites the whole list, so the next add/edit/delete/toggle of ANY webhook persisted "" over a blob that restoring the key file would have recovered — permanent destruction of key material, triggered by an unrelated admin action, contradicting the code's own "no data loss on a transient key-read failure" comment. Fix, security-first and minimal (no delivery behaviour widened): - The ciphertext survives. Webhook.sealedSecret (unexported) holds the value that failed to decrypt and save() writes it back verbatim. The in-memory cleartext is still dropped — never sign with a value that failed authentication — but the state is now recoverable. - The state is visible. List() reports a derived, read-only SigningDegraded ("signing_degraded"), carrying no key material. It is never accepted from a caller or from the file, and never persisted. - It reaches the operator. New alert_webhook_signing diagnostics row (counts only — no name, URL or ciphertext on a viewer-role surface) and an "Unsigned — secret unusable" badge in Security → Alert Webhooks. - packOne now refuses .alert_webhook_key alongside .kek, so a future dataDir walk can never ship the key with the material it unwraps. - Backup runbook: unencrypted-archive contents now name webhook endpoint URLs, and a new section 9.5 documents the non-portability and recovery. Deliberately not done: refusing delivery when the secret is unusable. That trades a visible authenticity failure for total alert silence, which is the worse outcome here. Recorded as an owner decision. Tests: positive, negative, regression (three fail against the pre-fix tree), boundary, malformed-input, recovery, trust-boundary and race-detector coverage in internal/alerts/webhook_signing_degraded_test.go, plus the operator-facing surfaces and the backup secret-containment wall in alert_webhook_signing_test.go. The rest of the window was reviewed and found safe — including an independent differential fuzz of the lock-free IPFilter read view against a verbatim copy of the pre-change algorithm (no divergence) — see docs/engineering/security-reviews/2026-08-24-socks5-ipfilter-and-backup-surface-window.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
❌ AegisDiff Security Triage —
|
| Field | Value |
|---|---|
| Verdict | ERROR |
| Severity | ✅ N/A |
| CWE | N/A |
| Confidence | 0% |
| Analyzed by | unknown |
| Sanitizer Found | ❌ None detected |
All LLM providers exhausted. Last error: Client error '404 Not Found' for url 'h
All LLM providers exhausted. Last error: Client error '404 Not Found' for url 'https://api.groq.com/openai/v1/chat/completions'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
📦 Large PR Risk Triage Mode coverage
AegisDiff ran in Large PR Risk Triage Mode. This PR exceeded the full-scan budget, so AegisDiff prioritized security-relevant changed hunks instead of scanning every line.
- Files changed: 14
- Files analyzed: 11
- Files skipped: 3
- Skip reasons:
docs: 3
- LLM calls used: 12 / 40
- Chunks errored: 12 / 12
⚠️ - Budget exhausted: no
- Large PR triggers: added_lines=1194 > 1000
- Inline comments posted: 0
…escription frontend/src/api/types.gen.ts is drift-gated against api/openapi/openapi.json, and its header embeds that file's sha256 — so documenting the read-only signing_degraded field on AlertWebhookList changed the generated output. Regenerated with the canonical script under the pinned toolchain (node 24.19.0 / npm 11.17.0, openapi-typescript v7.13.0); the diff is the source hash plus the one JSDoc line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0e28b449aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… tests golangci-lint (gosec G117) flags any json.Marshal of a struct carrying a field whose JSON key matches a secret pattern — here alerts.Webhook's "secret". All three sites marshal fixture or already-redacted data, so they carry an at-site #nosec with the reason, matching the existing suppression on Store.save's MarshalIndent. Verified with standalone gosec: 0 G117 issues in internal/alerts (14 nosec directives honored). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
Codex review on #1222: with the ciphertext now preserved, the documented "restore the original .alert_webhook_key" recovery had a trap. webhookSecretKey created a fresh key on ANY miss — including the miss that IS the restore-without-the-key case — so a restored node wrote a new key as a side effect of failing to decrypt. The preserved ciphertext then sat under a key that exists nowhere while every later write used the new one, and an operator who restored the original key afterwards would break exactly the secrets they had already re-entered. Key creation now happens only on the ENCRYPT path (webhookSecretKey takes an explicit create flag; webhookGCM passes true from encryptWebhookSecret and false from decryptWebhookSecret), so a node in the degraded state has no key file at all until the operator supplies one or writes a secret — which makes the key-restore path clean. It also stops a failed read from attempting a write on a read-only or full volume. Both operator surfaces now give the remedy in order: restore the original key first if you still have it (no re-entry needed), otherwise re-enter each secret, and never restore the old key after re-entering. Also annotates the gosec G101 fixture literals the lint gate flagged. Tests: TestSigningDegraded_FailedDecryptDoesNotMintAKey (no key file after a failed decrypt; the key appears on the first re-entry and round-trips). Verified with standalone gosec: 0 G101/G117 in the changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
#1222 review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
סיכום / Summary
Security regression review of the 2026-08-24 window (baseline
daf66d9— the tip of the 2026-08-23 review — through9b1ba86; 27 commits / 59 files). One regression found and fixed.It is not a bad line in the diff — it is a feature interaction. This window's nightly-QA backup fix added
alert_webhooks.jsonto the archive (4117bca). Webhook HMAC signing secrets in that file are AES-GCM ciphertext (RISK-003) under a node-local key,<dataDir>/.alert_webhook_key, which is deliberately never archived — the same rule that excludes.kekfiles, since a key must not share a tarball with the material it unwraps. That exclusion is correct. What it collides with is the alert store's long-standing response to a secret it cannot unwrap:deliverAttemptsetsX-Culvert-Signatureonly whenSecret != "". A receiver that verifies the HMAC then quietly stops accepting this node'sthreat_detected/policy_block/cert_expiryalerts — a monitoring blind spot where quiet looks exactly like healthy.List()redactsSecret(correctly), so "no signing secret was ever configured" and "the secret is unusable and deliveries are unsigned" serialised to byte-identical JSON. The only signal was oneobs.Printfat boot — and the alerting plane cannot page about its own signing being broken.save()rewrites the whole list, so the next add/edit/delete/toggle of any webhook re-encrypted the blanked cleartext and persisted""over a blob that restoring the key file would have recovered. The code's own comment claimed "no data loss on a transient key-read failure"; that intent was not upheld.Reachability is what changed: from "essentially never" to every rebuild-from-backup, a supported and documented operator workflow (an unreadable key file at boot — permissions, or the EMFILE window CHAOS-54 documents in this same window — gets there too). All three steps were reproduced against the pre-fix tree before the fix, and three of the new tests fail against it.
סוג שינוי / Change type:
security— תיקון אבטחה / security fixThe fix
Security-first and minimal; no delivery behaviour widened, nothing made more permissive:
Webhook.sealedSecret(unexported) holds the value that failed to decrypt, andsave()writes it back verbatim instead of re-encrypting the blanked cleartext. The in-memorySecretstays empty — we still never sign with a value that failed authentication — but the failure is now recoverable rather than destructive.Updatecarries it across a secret-less edit; supplying a new secret replaces it.List()reports a derived, read-onlySigningDegraded(signing_degradedon the wire) — the one bit an operator needs, carrying no key material. It is derived, never accepted:Add,Updateand the on-disk document cannot assert it, and it is never persisted.alert_webhook_signingdiagnostics row (OK/warn, counts only — no name, URL or ciphertext on that viewer-role surface) plus an amber "Unsigned — secret unusable" badge in Security → Alert Webhooks.packOnenow refuses.alert_webhook_keyalongside.kek, so a futuredataDirwalk or glob can never ship the key with the material it unwraps. The backup runbook's "what an unencrypted archive contains" list now names webhook endpoint URLs (bearer credentials for most receivers), and a new § 9.5 documents the non-portability and how to recover.Deliberately not done: refusing delivery outright when the secret is unusable. That trades a visible authenticity failure for total alert silence, which is worse against the realistic scenario. Recorded as an owner decision in the review doc.
The rest of the window: reviewed, found safe
Full write-up in
docs/engineering/security-reviews/2026-08-24-socks5-ipfilter-and-backup-surface-window.md. Highlights:::ffff:10.0.0.0/104,::ffff:0.0.0.0/96, 4-in-6 probes, zoned addresses,/0,/32,/128, malformed probes) plus 400 randomised taxonomies × 60 probes. No divergence. Fail-closeddefaultarm intact; every mutator republishes;ClearAll≡ theList+Removeloop it replaced./health+/readyzcarry a fixed enum / fixed detail while the reason class and counts stay role-gated; bounded alert Detail; separate degraded/down latches.frontend/distbundle was checked directly (the "Server detail:" rendering is gone); the decoder relaxation keeps theui_tls_fallbackflag required./healthz+/metricsSOCKS5 series, ADR-number comment corrections, the PAC label clarification, and four forward dependency/action bumps: all safe.בדיקות שבוצעו / Testing Done
golangci-lintcould not run in this environment (the installed binary is built with Go 1.25; the module requires Go 1.26) — left to the Fast PR Gate.New coverage, by kind:
TestSigningDegraded_UndecryptableCiphertextSurvivesUnrelatedSave,…_DeleteOfAnotherHookDoesNotDestroyTheCiphertext,…_UpdateWithoutSecretPreservesCiphertextTestSigningDegraded_HealthyStoreIsNotDegraded,TestDiagnostics_AlertWebhookSigningRow_OKWhenHealthyTestSigningDegraded_ListReportsItWithoutLeaking,TestDiagnostics_AlertWebhookSigningRow_WarnsWhenDegraded,TestAlertWebhookList_ExposesSigningDegradedWithoutSecretsTestSigningDegraded_ReEnteringTheSecretRecovers,TestSigningDegraded_RestoringTheKeyFileRecoversSigningTestSigningDegraded_MalformedStoredSecret(non-base64, empty, truncated)TestSigningDegraded_StatusIsNeverAcceptedFromACaller(create, update, hand-edited store file)-race)TestSigningDegraded_ConcurrentReadersAndWritersTestBackup_NeverPacksTheWebhookSigningKey,TestIsNodeLocalKeyArtifactPathalert_webhook_signingpinned intodiagnostics_test.go's required rows +TestDiagnostics_AlertWebhookSigningRow_InDefaultReportרשימת בדיקות לפני Merge / Pre-Merge Checklist
קוד / Code Quality
go vet ./...+go build ./...passgolangci-lint run— could not run locally (Go-version mismatch in this environment); deferred to the Fast PR GateTODOs, or debuglog.Printf(the scratch harnesses used during the review were deleted)go.modunchangedאבטחה / Security
signing_degradedis server-derived and explicitly ignored from callers and from the fileInsecureSkipVerifyauth*.go,ca.goorproxy.goאם נגעת ב-Policy Engine
אם נגעת ב-Frontend (index.html)
innerHTMLunescapedאם הוספת/שינית endpoint של ה-API
GET /api/alerts/webhooks(AlertWebhookListitems areadditionalProperties: true, so the shape stays valid), documented inapi/openapi/openapi.yamlmake api-bundlerun; the commit includes the regeneratedopenapi.json(and-checkreports up to date)x-culvert-*metadata unchanged and still matches handler behaviouromitemptyand read-onlyתיעוד / Documentation
docs/operator/docker-compose-backup-restore.md— § 4 contents list + new § 9.5CLAUDE.md— the invariant recorded beside the other alerts-plane rulesdocs/engineering/security-reviews/2026-08-24-…— the full reviewCHANGELOG.mdin this repo;config.example.yamlunaffected (no new config field — enablement is not operator-configurable)סיכון ו-Rollback / Risk & Rollback
רמת סיכון / Risk level: 🟢 Low
The change is confined to
internal/alertspersistence bookkeeping plus three read-only surfaces. On a healthy node — every webhook secret decrypting normally —sealedSecretis always empty,save()takes exactly the previous branch, andsigning_degradedis omitted from the wire, so behaviour is byte-identical. The only behavioural delta is on a node that was already broken, where it now preserves rather than destroys.Rollback: revert the commit. No migration, no on-disk format change (the preserved value is the same
enc:v1:blob the previous code wrote), no config surface touched. A reverted node simply returns to blanking the ciphertext on the next save.Residual risk (recorded, not fixed here)
ui_usershashes, TOTP secrets). Dev/lab only; the doc now enumerates the URLs.ipfis a plain package global reassigned by the DP snapshot apply while request goroutines read it — an unsynchronised pointer swap, pre-existing, same shape as the other snapshot-applied globals.🤖 Generated with Claude Code
https://claude.ai/code/session_01FcJECueYSXcU6H3GXkrumw
Generated by Claude Code