Skip to content

feat(security): encrypt NotificationChannel.config secrets at rest#198

Merged
TerrifiedBug merged 9 commits intomainfrom
feat/encrypt-channel-secrets
Apr 28, 2026
Merged

feat(security): encrypt NotificationChannel.config secrets at rest#198
TerrifiedBug merged 9 commits intomainfrom
feat/encrypt-channel-secrets

Conversation

@TerrifiedBug
Copy link
Copy Markdown
Owner

Summary

  • Add channel-secrets.ts service with per-type sensitive-field map (webhook: hmacSecret + headers; slack: webhookUrl; pagerduty: integrationKey; email: smtpPass)
  • Encrypt sensitive sub-fields on createChannel and updateChannel (with rotation + PRESERVE_IF_ABSENT preservation)
  • Decrypt in deliverToChannels dispatcher (both tracked + untracked paths) and testChannel
  • Idempotent via v2: prefix detection — re-running encryption is a no-op
  • One-shot backfill script: pnpm backfill:channel-secrets
  • Drivers (webhook/slack/pagerduty/email) untouched; receive plaintext config as before

Closes the secret-encryption gap that persisted after the AlertWebhook → NotificationChannel migration (#197). Removes plaintext storage of webhook signing secrets, custom auth headers, PagerDuty routing keys, Slack webhook URLs (token in path), and SMTP passwords.

Out of scope

  • Audit log redaction for these field names — filed as separate P1 follow-up. Pre-existing gap; encryption-at-rest still helps because audit log access is more privileged than DB read access.
  • HKDF key cache — crypto.ts re-derives the key per encrypt/decrypt call. Hot-path concern for high-volume environments; out of scope for this change.

Test plan

  • Unit: encrypt/decrypt round-trip per channel type (webhook with structured headers, slack, pagerduty, email)
  • Unit: idempotency (no double-encrypt; decrypt is no-op on plaintext)
  • Router: createChannel persists ciphertext; non-sensitive fields stay plaintext
  • Router: updateChannel rotates secret to fresh ciphertext; preserves existing v2: ciphertext when input lacks the field
  • Dispatcher: deliverToChannels decrypts before driver.deliver (both tracked and untracked paths)
  • Router: testChannel decrypts before driver.test
  • Type check + lint pass
  • Manual smoke (after merge, in dev): create webhook channel via UI → DB row has v2: prefix on hmacSecret; trigger test delivery; update channel preserving secret
  • Run pnpm backfill:channel-secrets against dev DB; re-run to confirm idempotent skip

Migration note

After merge, run the backfill script once against any environment with existing NotificationChannel rows to encrypt at-rest values. Idempotent — safe to re-run.

@github-actions github-actions Bot added feature dependencies Pull requests that update a dependency file and removed feature labels Apr 28, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95626947de

ℹ️ 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".

Comment on lines +4 to +5
webhook: ["hmacSecret", "headers"],
slack: ["webhookUrl"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decrypt encrypted config fields before exposing edit config

Adding webhook.headers and slack.webhookUrl to the encrypted field map causes those values to be stored as v2: ciphertext, but listChannels still returns channel config values as-is for those keys. The UI edit flow reads them directly (formFromConfig) and then re-validates as URL/JSON-object on submit, so edited Slack channels and webhook channels with headers can fail updates unless users manually re-enter those values. This introduces a regression in the channel-edit workflow after encryption is enabled.

Useful? React with 👍 / 👎.

Comment thread src/server/services/channel-secrets.ts Outdated
const V2_PREFIX = "v2:";

function isAlreadyEncrypted(value: unknown): boolean {
return typeof value === "string" && value.startsWith(V2_PREFIX);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace prefix-only check for already-encrypted secrets

The isAlreadyEncrypted heuristic treats any string beginning with v2: as ciphertext, so legitimate plaintext secrets like v2:token are skipped during encryption and stored unencrypted. At delivery/test time, decryptChannelConfig attempts to decrypt those values and throws, which breaks channel test/delivery for affected configs. This should use a verifiable ciphertext format check (or explicit metadata) rather than a bare prefix test.

Useful? React with 👍 / 👎.

Three regressions surfaced in Codex review of #198:

1. Retry delivery paths skipped decryption — both src/server/routers/
   alert-deliveries.ts (manual retry) and src/server/services/retry-service.ts
   (background retry loop) now wrap channel.config in decryptChannelConfig
   before driver.deliver, matching deliverToChannels and testChannel.

2. listChannels returned encrypted webhookUrl/headers, breaking the edit
   dialog on slack/webhook channels. Decrypt the channel config before
   applying the existing redaction pass; truly-secret fields (hmacSecret,
   smtpPass, integrationKey) stay masked, while non-redacted fields needed
   by the form (webhookUrl, headers) reach the UI as plaintext.

3. Replaced the "v2:" idempotency-marker (which collided with crypto.ts's
   own ciphertext prefix and any user secret beginning "v2:") with a
   channel-secrets-specific wrapper "vfenc1:". On-disk format becomes
   vfenc1:v2:<base64>; isAlreadyEncrypted now keys off vfenc1: only.
@TerrifiedBug TerrifiedBug merged commit 60958b5 into main Apr 28, 2026
10 checks passed
@TerrifiedBug TerrifiedBug deleted the feat/encrypt-channel-secrets branch April 28, 2026 12:00
TerrifiedBug added a commit that referenced this pull request Apr 28, 2026
The audit middleware sanitizer (src/server/middleware/audit.ts) only
redacted exact-key matches for password/token/secret/etc. Channel-config
secret fields slipped through unredacted, causing AuditLog.metadata to
store plaintext webhook signing secrets, SMTP passwords, PagerDuty
routing keys, and webhook URLs (which often embed auth tokens) on every
notificationChannel.created / .updated / .deleted action.

This undermined the encrypt-at-rest fix landed in PR #198, which closed
plaintext storage in the channel.config column itself but left the
audit-log side channel open.

Changes:
- Add hmacSecret, smtpPass, integrationKey, webhookUrl to SENSITIVE_KEYS.
- Extract SENSITIVE_KEYS / sanitizeInput / computeDiff into a separate
  audit-sanitize module so they can be unit-tested without pulling in
  the full Prisma + NextAuth runtime via the middleware barrel.
- Add unit tests covering top-level redaction, nested config redaction,
  arrays of channels, primitive passthrough, null/undefined, and
  presence of the new keys in the set.
TerrifiedBug added a commit that referenced this pull request Apr 28, 2026
…log (#200)

* fix(audit): redact NotificationChannel secret config fields in audit log

The audit middleware sanitizer (src/server/middleware/audit.ts) only
redacted exact-key matches for password/token/secret/etc. Channel-config
secret fields slipped through unredacted, causing AuditLog.metadata to
store plaintext webhook signing secrets, SMTP passwords, PagerDuty
routing keys, and webhook URLs (which often embed auth tokens) on every
notificationChannel.created / .updated / .deleted action.

This undermined the encrypt-at-rest fix landed in PR #198, which closed
plaintext storage in the channel.config column itself but left the
audit-log side channel open.

Changes:
- Add hmacSecret, smtpPass, integrationKey, webhookUrl to SENSITIVE_KEYS.
- Extract SENSITIVE_KEYS / sanitizeInput / computeDiff into a separate
  audit-sanitize module so they can be unit-tested without pulling in
  the full Prisma + NextAuth runtime via the middleware barrel.
- Add unit tests covering top-level redaction, nested config redaction,
  arrays of channels, primitive passthrough, null/undefined, and
  presence of the new keys in the set.

* fix(settings): add Outbound Webhooks entry to settings sidebar nav

PR #199 wired the page route and the settings-overview card but left
settings-sidebar-nav.tsx untouched, so the page was unreachable from
the sidebar. Add it to the Organization group alongside Service
Accounts and AI (other team-level integrations) with the Webhook icon.
Non-super-admin so team editors can manage event subscriptions, demo-
hidden to match other integration entries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant