diff --git a/package.json b/package.json index b24ca9d3..399eab1d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "postinstall": "prisma generate", - "generate:openapi": "tsx scripts/generate-openapi.ts" + "generate:openapi": "tsx scripts/generate-openapi.ts", + "backfill:channel-secrets": "tsx scripts/backfill-channel-secrets.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/scripts/backfill-channel-secrets.ts b/scripts/backfill-channel-secrets.ts new file mode 100644 index 00000000..3a4f6d55 --- /dev/null +++ b/scripts/backfill-channel-secrets.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env tsx +import { prisma } from "@/lib/prisma"; +import { encryptChannelConfig, SENSITIVE_FIELDS_BY_TYPE } from "@/server/services/channel-secrets"; + +async function main() { + const channels = await prisma.notificationChannel.findMany({ + select: { id: true, type: true, config: true }, + }); + + let updated = 0, skipped = 0; + for (const ch of channels) { + if (!SENSITIVE_FIELDS_BY_TYPE[ch.type]) { skipped++; continue; } + const config = ch.config as Record; + const encrypted = encryptChannelConfig(ch.type, config); + // Only write if at least one field actually changed (idempotent skip) + const changed = Object.keys(encrypted).some((k) => encrypted[k] !== config[k]); + if (!changed) { skipped++; continue; } + await prisma.notificationChannel.update({ + where: { id: ch.id }, + data: { config: encrypted as never }, + }); + updated++; + console.log(`encrypted channel ${ch.id} (${ch.type})`); + } + console.log(`done — updated=${updated} skipped=${skipped} total=${channels.length}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/server/routers/__tests__/alert-channels.test.ts b/src/server/routers/__tests__/alert-channels.test.ts index fe078fda..4244a2c0 100644 --- a/src/server/routers/__tests__/alert-channels.test.ts +++ b/src/server/routers/__tests__/alert-channels.test.ts @@ -50,6 +50,8 @@ vi.mock("@/server/services/channels", () => ({ import { prisma } from "@/lib/prisma"; import { alertChannelsRouter } from "@/server/routers/alert-channels"; +import { encrypt, ENCRYPTION_DOMAINS } from "@/server/services/crypto"; +import { ENCRYPTED_MARKER } from "@/server/services/channel-secrets"; const prismaMock = prisma as unknown as DeepMockProxy; const caller = t.createCallerFactory(alertChannelsRouter)({ @@ -110,6 +112,44 @@ describe("alertChannelsRouter", () => { expect(result).toEqual([]); }); + + it("decrypts non-redacted fields so the edit form receives plaintext", async () => { + const encryptedUrl = ENCRYPTED_MARKER + encrypt( + "https://hooks.slack.com/services/T/B/SECRETTOKEN", + ENCRYPTION_DOMAINS.SECRETS, + ); + + prismaMock.notificationChannel.findMany.mockResolvedValue([ + makeChannel({ + type: "slack", + config: { webhookUrl: encryptedUrl }, + }), + ] as never); + + const result = await caller.listChannels({ environmentId: "env-1" }); + + const config = result[0].config as Record; + expect(config.webhookUrl).toBe("https://hooks.slack.com/services/T/B/SECRETTOKEN"); + }); + + it("decrypts encrypted webhook headers and surfaces them as an object", async () => { + const encryptedHeaders = ENCRYPTED_MARKER + encrypt( + JSON.stringify({ Authorization: "Bearer abc" }), + ENCRYPTION_DOMAINS.SECRETS, + ); + + prismaMock.notificationChannel.findMany.mockResolvedValue([ + makeChannel({ + type: "webhook", + config: { url: "https://x.test", headers: encryptedHeaders }, + }), + ] as never); + + const result = await caller.listChannels({ environmentId: "env-1" }); + + const config = result[0].config as Record; + expect(config.headers).toEqual({ Authorization: "Bearer abc" }); + }); }); // ─── createChannel ───────────────────────────────────────────────────────── @@ -237,6 +277,25 @@ describe("alertChannelsRouter", () => { }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); + + it("encrypts hmacSecret before persisting webhook channel", async () => { + prismaMock.environment.findUnique.mockResolvedValue({ id: "env-1" } as never); + prismaMock.notificationChannel.create.mockResolvedValue( + makeChannel({ type: "webhook" }) as never, + ); + + await caller.createChannel({ + environmentId: "env-1", + name: "Webhook With Secret", + type: "webhook", + config: { url: "https://example.com/hook", hmacSecret: "raw-secret" }, + }); + + const createCall = prismaMock.notificationChannel.create.mock.calls[0][0]; + const persisted = (createCall as { data: { config: Record } }).data.config; + expect(persisted.hmacSecret).toMatch(/^vfenc1:/); + expect(persisted.url).toBe("https://example.com/hook"); + }); }); // ─── updateChannel ───────────────────────────────────────────────────────── @@ -289,6 +348,43 @@ describe("alertChannelsRouter", () => { expect(mockValidatePublicUrl).toHaveBeenCalledWith("https://hooks.slack.com/new"); }); + + it("re-encrypts hmacSecret when rotated via update", async () => { + const existing = makeChannel({ + type: "webhook", + config: { url: "https://x", hmacSecret: "vfenc1:v2:OLDCIPHERTEXT" }, + }); + prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never); + prismaMock.notificationChannel.update.mockResolvedValue(existing as never); + + await caller.updateChannel({ + id: "ch-1", + config: { url: "https://x", hmacSecret: "new-raw" }, + }); + + const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0]; + const persisted = (updateCall as { data: { config: Record } }).data.config; + expect(persisted.hmacSecret).toMatch(/^vfenc1:/); + expect(persisted.hmacSecret).not.toBe("vfenc1:v2:OLDCIPHERTEXT"); + }); + + it("preserves existing encrypted hmacSecret when not in input", async () => { + const existing = makeChannel({ + type: "webhook", + config: { url: "https://x", hmacSecret: "vfenc1:v2:OLDCIPHERTEXT" }, + }); + prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never); + prismaMock.notificationChannel.update.mockResolvedValue(existing as never); + + await caller.updateChannel({ + id: "ch-1", + config: { url: "https://newurl" }, + }); + + const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0]; + const persisted = (updateCall as { data: { config: Record } }).data.config; + expect(persisted.hmacSecret).toBe("vfenc1:v2:OLDCIPHERTEXT"); + }); }); // ─── deleteChannel ───────────────────────────────────────────────────────── @@ -352,5 +448,30 @@ describe("alertChannelsRouter", () => { caller.testChannel({ id: "ch-missing" }), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); + + it("decrypts hmacSecret before passing config to driver.test", async () => { + const encryptedSecret = ENCRYPTED_MARKER + encrypt("raw-secret", ENCRYPTION_DOMAINS.SECRETS); + expect(encryptedSecret.startsWith("vfenc1:")).toBe(true); + + prismaMock.notificationChannel.findUnique.mockResolvedValue( + makeChannel({ + type: "webhook", + config: { + url: "https://hooks.example.com/x", + hmacSecret: encryptedSecret, + }, + }) as never, + ); + mockChannelTest.mockResolvedValue({ success: true }); + + await caller.testChannel({ id: "ch-1" }); + + expect(mockChannelTest).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://hooks.example.com/x", + hmacSecret: "raw-secret", + }), + ); + }); }); }); diff --git a/src/server/routers/alert-channels.ts b/src/server/routers/alert-channels.ts index bb297e8a..49f1fbbe 100644 --- a/src/server/routers/alert-channels.ts +++ b/src/server/routers/alert-channels.ts @@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; import { validatePublicUrl, validateSmtpHost } from "@/server/services/url-validation"; import { getDriver } from "@/server/services/channels"; +import { encryptChannelConfig, decryptChannelConfig } from "@/server/services/channel-secrets"; export const alertChannelsRouter = router({ listChannels: protectedProcedure @@ -27,12 +28,17 @@ export const alertChannelsRouter = router({ orderBy: { createdAt: "desc" }, }); - // Redact sensitive config fields before sending to the client + // Decrypt sensitive fields, then redact the truly-secret ones before + // returning. The edit-form needs plaintext for non-redacted fields + // (slack webhookUrl, webhook headers); redacted fields are masked here + // and PRESERVE_IF_ABSENT carries them forward on save. return channels.map((ch) => { - const config = ch.config as Record; - const safeConfig = { ...config }; + const decrypted = decryptChannelConfig( + ch.type, + ch.config as Record, + ); + const safeConfig = { ...decrypted }; - // Redact passwords and secrets if ("smtpPass" in safeConfig) safeConfig.smtpPass = "••••••••"; if ("hmacSecret" in safeConfig && safeConfig.hmacSecret) safeConfig.hmacSecret = "••••••••"; @@ -111,7 +117,7 @@ export const alertChannelsRouter = router({ environmentId: input.environmentId, name: input.name, type: input.type, - config: input.config as Prisma.InputJsonValue, + config: encryptChannelConfig(input.type, input.config) as Prisma.InputJsonValue, }, }); }), @@ -194,12 +200,15 @@ export const alertChannelsRouter = router({ } } + const encryptedConfig = + config !== undefined ? encryptChannelConfig(existing.type, config) : undefined; + return prisma.notificationChannel.update({ where: { id }, data: { ...rest, - ...(config !== undefined - ? { config: config as Prisma.InputJsonValue } + ...(encryptedConfig !== undefined + ? { config: encryptedConfig as Prisma.InputJsonValue } : {}), }, }); @@ -243,9 +252,11 @@ export const alertChannelsRouter = router({ try { const driver = getDriver(channel.type); - const result = await driver.test( + const decrypted = decryptChannelConfig( + channel.type, channel.config as Record, ); + const result = await driver.test(decrypted); return { success: result.success, error: result.error, diff --git a/src/server/routers/alert-deliveries.ts b/src/server/routers/alert-deliveries.ts index e781739e..6bb168ca 100644 --- a/src/server/routers/alert-deliveries.ts +++ b/src/server/routers/alert-deliveries.ts @@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; import type { ChannelPayload } from "@/server/services/channels/types"; import { getDriver } from "@/server/services/channels"; +import { decryptChannelConfig } from "@/server/services/channel-secrets"; export const alertDeliveriesRouter = router({ listDeliveries: protectedProcedure @@ -134,7 +135,10 @@ export const alertDeliveriesRouter = router({ channel.type, channel.name, async () => { - const result = await channelDriver.deliver(channel.config as Record, payload); + const result = await channelDriver.deliver( + decryptChannelConfig(channel.type, channel.config as Record), + payload, + ); return { success: result.success, error: result.error }; }, nextAttemptNumber, diff --git a/src/server/services/__tests__/channel-secrets.test.ts b/src/server/services/__tests__/channel-secrets.test.ts new file mode 100644 index 00000000..01ec120c --- /dev/null +++ b/src/server/services/__tests__/channel-secrets.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { encryptChannelConfig, decryptChannelConfig } from "../channel-secrets"; + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = "test-secret-for-vitest-runs-only"; +}); + +describe("encryptChannelConfig", () => { + it("encrypts webhook hmacSecret as vfenc1:-prefixed ciphertext", () => { + const out = encryptChannelConfig("webhook", { + url: "https://example.com/hook", + hmacSecret: "raw-hmac-secret", + }); + expect(out.url).toBe("https://example.com/hook"); + expect(typeof out.hmacSecret).toBe("string"); + expect(out.hmacSecret as string).toMatch(/^vfenc1:/); + }); + + it("does not treat user-supplied secrets that begin with v2: as already encrypted", () => { + const out = encryptChannelConfig("webhook", { hmacSecret: "v2:user-token" }); + expect(out.hmacSecret as string).toMatch(/^vfenc1:/); + const round = decryptChannelConfig("webhook", out); + expect(round.hmacSecret).toBe("v2:user-token"); + }); +}); + +describe("encrypt+decrypt round trip", () => { + it.each([ + ["webhook", { url: "https://x.test/h", hmacSecret: "s1", headers: { Authorization: "Bearer abc" } }], + ["slack", { webhookUrl: "https://hooks.slack.com/services/T/B/XYZ" }], + ["pagerduty", { integrationKey: "ROUTING-KEY-123", severityMap: { error: "warning" } }], + ["email", { smtpHost: "smtp.x", smtpUser: "u", smtpPass: "p", recipients: ["a@b"] }], + ])("round-trips %s", (type, plain) => { + const enc = encryptChannelConfig(type, plain); + const dec = decryptChannelConfig(type, enc); + expect(dec).toEqual(plain); + }); +}); + +describe("idempotency", () => { + it("does not re-encrypt already vfenc1-prefixed values", () => { + const once = encryptChannelConfig("webhook", { hmacSecret: "raw" }); + const twice = encryptChannelConfig("webhook", once); + expect(twice.hmacSecret).toBe(once.hmacSecret); + }); + + it("decrypt is no-op on plaintext", () => { + const out = decryptChannelConfig("webhook", { hmacSecret: "plain" }); + expect(out.hmacSecret).toBe("plain"); + }); +}); + +describe("unknown type", () => { + it("returns config unchanged for unknown channel type", () => { + const cfg = { foo: "bar" }; + expect(encryptChannelConfig("unknown", cfg)).toEqual(cfg); + expect(decryptChannelConfig("unknown", cfg)).toEqual(cfg); + }); +}); diff --git a/src/server/services/channel-secrets.ts b/src/server/services/channel-secrets.ts new file mode 100644 index 00000000..3c43d6bf --- /dev/null +++ b/src/server/services/channel-secrets.ts @@ -0,0 +1,60 @@ +import { encrypt, decrypt, ENCRYPTION_DOMAINS } from "./crypto"; + +export const SENSITIVE_FIELDS_BY_TYPE: Record = { + webhook: ["hmacSecret", "headers"], + slack: ["webhookUrl"], + pagerduty: ["integrationKey"], + email: ["smtpPass"], +}; + +// Channel-secrets-specific wrapper around crypto.ts ciphertext. +// Distinguishes our encrypted blobs from arbitrary user input that may happen +// to begin with crypto.ts's own "v2:" prefix (e.g., a literal secret token). +export const ENCRYPTED_MARKER = "vfenc1:"; + +function isAlreadyEncrypted(value: unknown): boolean { + return typeof value === "string" && value.startsWith(ENCRYPTED_MARKER); +} + +function serializeForEncryption(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +export function encryptChannelConfig( + type: string, + config: Record, +): Record { + const fields = SENSITIVE_FIELDS_BY_TYPE[type]; + if (!fields) return config; + + const out: Record = { ...config }; + for (const field of fields) { + const value = out[field]; + if (value === undefined || value === null || value === "") continue; + if (isAlreadyEncrypted(value)) continue; + out[field] = ENCRYPTED_MARKER + encrypt(serializeForEncryption(value), ENCRYPTION_DOMAINS.SECRETS); + } + return out; +} + +export function decryptChannelConfig( + type: string, + config: Record, +): Record { + const fields = SENSITIVE_FIELDS_BY_TYPE[type]; + if (!fields) return config; + + const out: Record = { ...config }; + for (const field of fields) { + const value = out[field]; + if (typeof value !== "string" || !value.startsWith(ENCRYPTED_MARKER)) continue; + const ciphertext = value.slice(ENCRYPTED_MARKER.length); + const plaintext = decrypt(ciphertext, ENCRYPTION_DOMAINS.SECRETS); + if (field === "headers") { + out[field] = JSON.parse(plaintext); + } else { + out[field] = plaintext; + } + } + return out; +} diff --git a/src/server/services/channels/__tests__/index.test.ts b/src/server/services/channels/__tests__/index.test.ts new file mode 100644 index 00000000..ac91e12e --- /dev/null +++ b/src/server/services/channels/__tests__/index.test.ts @@ -0,0 +1,136 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +// ─── Hoisted mocks ────────────────────────────────────────────────────────── +// +// vi.mock() factories run before module imports, so any locally-defined +// value referenced inside them must be hoisted with vi.hoisted(). + +const { fakeWebhookDriver } = vi.hoisted(() => ({ + fakeWebhookDriver: { + deliver: vi.fn(), + test: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +// Replace the real webhook driver with a spy so we can assert what config +// the dispatcher hands it. The driver itself is tested in webhook.test.ts. +vi.mock("@/server/services/channels/webhook", () => ({ + webhookDriver: fakeWebhookDriver, +})); + +// Avoid creating real DeliveryAttempt rows when alertEventId is passed. +vi.mock("@/server/services/delivery-tracking", () => ({ + trackChannelDelivery: vi.fn( + async ( + _alertEventId: string, + _channelId: string, + _channelType: string, + _channelName: string, + deliverFn: () => Promise<{ success: boolean; error?: string }>, + ) => deliverFn(), + ), +})); + +import { prisma } from "@/lib/prisma"; +import { deliverToChannels } from "@/server/services/channels"; +import type { ChannelPayload } from "@/server/services/channels/types"; +import { encrypt, ENCRYPTION_DOMAINS } from "@/server/services/crypto"; +import { ENCRYPTED_MARKER } from "@/server/services/channel-secrets"; + +const prismaMock = prisma as unknown as DeepMockProxy; + +function makePayload(): ChannelPayload { + return { + alertId: "alert-1", + status: "firing", + ruleName: "CPU High", + severity: "warning", + environment: "Production", + metric: "cpu_usage", + value: 92.5, + threshold: 80, + message: "CPU usage is 92.50 (threshold: > 80)", + timestamp: "2026-03-31T12:00:00.000Z", + dashboardUrl: "https://vf.example.com/alerts", + }; +} + +describe("deliverToChannels — secret decryption at driver boundary", () => { + beforeEach(() => { + mockReset(prismaMock); + fakeWebhookDriver.deliver.mockReset(); + fakeWebhookDriver.test.mockReset(); + delete process.env.NEXT_PUBLIC_VF_DEMO_MODE; + }); + + it("decrypts hmacSecret before passing config to webhook driver (broadcast path)", async () => { + fakeWebhookDriver.deliver.mockResolvedValue({ + channelId: "ch1", + success: true, + }); + + const encryptedSecret = ENCRYPTED_MARKER + encrypt("raw-secret", ENCRYPTION_DOMAINS.SECRETS); + expect(encryptedSecret.startsWith("vfenc1:")).toBe(true); + + prismaMock.notificationChannel.findMany.mockResolvedValue([ + { + id: "ch1", + name: "Webhook 1", + type: "webhook", + config: { + url: "https://hooks.example.com/x", + hmacSecret: encryptedSecret, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + + await deliverToChannels("env-1", null, makePayload()); + + expect(fakeWebhookDriver.deliver).toHaveBeenCalledTimes(1); + expect(fakeWebhookDriver.deliver).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://hooks.example.com/x", + hmacSecret: "raw-secret", + }), + expect.objectContaining({ alertId: "alert-1" }), + ); + }); + + it("decrypts hmacSecret on the tracked-delivery path (alertEventId provided)", async () => { + fakeWebhookDriver.deliver.mockResolvedValue({ + channelId: "ch1", + success: true, + }); + + const encryptedSecret = ENCRYPTED_MARKER + encrypt("tracked-secret", ENCRYPTION_DOMAINS.SECRETS); + + prismaMock.notificationChannel.findMany.mockResolvedValue([ + { + id: "ch1", + name: "Webhook 1", + type: "webhook", + config: { + url: "https://hooks.example.com/x", + hmacSecret: encryptedSecret, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + + await deliverToChannels("env-1", null, makePayload(), "alert-event-1"); + + expect(fakeWebhookDriver.deliver).toHaveBeenCalledTimes(1); + const [configArg] = fakeWebhookDriver.deliver.mock.calls[0]; + expect(configArg).toMatchObject({ + url: "https://hooks.example.com/x", + hmacSecret: "tracked-secret", + }); + }); +}); diff --git a/src/server/services/channels/index.ts b/src/server/services/channels/index.ts index 37ec142f..48436351 100644 --- a/src/server/services/channels/index.ts +++ b/src/server/services/channels/index.ts @@ -7,6 +7,7 @@ import { pagerdutyDriver } from "./pagerduty"; import { webhookDriver } from "./webhook"; import { trackChannelDelivery } from "@/server/services/delivery-tracking"; import { isDemoMode } from "@/lib/is-demo-mode"; +import { decryptChannelConfig } from "@/server/services/channel-secrets"; export type { ChannelPayload, ChannelDeliveryResult, ChannelDriver }; @@ -103,10 +104,11 @@ export async function deliverToChannels( channel.name, async () => { const driver = getDriver(channel.type); - const result = await driver.deliver( + const decrypted = decryptChannelConfig( + channel.type, channel.config as Record, - payload, ); + const result = await driver.deliver(decrypted, payload); return { success: result.success, error: result.error }; }, ); @@ -115,10 +117,11 @@ export async function deliverToChannels( // Untracked delivery (no alertEventId context) try { const driver = getDriver(channel.type); - const result = await driver.deliver( + const decrypted = decryptChannelConfig( + channel.type, channel.config as Record, - payload, ); + const result = await driver.deliver(decrypted, payload); results.push({ ...result, channelId: channel.id }); } catch (err) { errorLog("channels", `Channel delivery error (${channel.type} / ${channel.id})`, err); diff --git a/src/server/services/retry-service.ts b/src/server/services/retry-service.ts index e5590761..fa0bbbc5 100644 --- a/src/server/services/retry-service.ts +++ b/src/server/services/retry-service.ts @@ -4,6 +4,7 @@ import { getNextRetryAt, } from "@/server/services/delivery-tracking"; import { getDriver } from "@/server/services/channels"; +import { decryptChannelConfig } from "@/server/services/channel-secrets"; import type { ChannelPayload } from "@/server/services/channels/types"; import { deliverOutboundWebhook, isPermanentFailure } from "@/server/services/outbound-webhook"; import { infoLog, errorLog } from "@/lib/logger"; @@ -277,7 +278,7 @@ export class RetryService { async () => { const driver = getDriver(channel.type); const driverResult = await driver.deliver( - channel.config as Record, + decryptChannelConfig(channel.type, channel.config as Record), payload, ); return { success: driverResult.success, error: driverResult.error };