diff --git a/src/components/settings-sidebar-nav.tsx b/src/components/settings-sidebar-nav.tsx index bb25c32f..7417e0dd 100644 --- a/src/components/settings-sidebar-nav.tsx +++ b/src/components/settings-sidebar-nav.tsx @@ -12,6 +12,7 @@ import { Sparkles, Activity, Send, + Webhook, } from "lucide-react"; export const settingsNavGroups = [ @@ -37,6 +38,7 @@ export const settingsNavGroups = [ { title: "All Teams", href: "/settings/teams", icon: Building2, requiredSuperAdmin: true }, { title: "My Team", href: "/settings/team", icon: Users, requiredSuperAdmin: false, demoHidden: true }, { title: "Service Accounts", href: "/settings/service-accounts", icon: Bot, requiredSuperAdmin: false, demoHidden: true }, + { title: "Outbound Webhooks", href: "/settings/webhooks", icon: Webhook, requiredSuperAdmin: false, demoHidden: true }, { title: "AI", href: "/settings/ai", icon: Sparkles, requiredSuperAdmin: false }, ], }, diff --git a/src/server/middleware/__tests__/audit.test.ts b/src/server/middleware/__tests__/audit.test.ts new file mode 100644 index 00000000..597eae09 --- /dev/null +++ b/src/server/middleware/__tests__/audit.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeInput, SENSITIVE_KEYS } from "../audit-sanitize"; + +describe("sanitizeInput", () => { + it("redacts top-level sensitive keys", () => { + const out = sanitizeInput({ + name: "alice", + password: "p@ss", + token: "abc", + }); + expect(out).toEqual({ + name: "alice", + password: "[REDACTED]", + token: "[REDACTED]", + }); + }); + + it("redacts NotificationChannel config secret fields", () => { + const out = sanitizeInput({ + name: "Slack ops", + config: { + webhookUrl: "https://hooks.slack.com/services/T0/B0/secret-token", + hmacSecret: "shhh", + smtpPass: "smtp-pw", + integrationKey: "pd-routing-key", + }, + }); + expect(out).toEqual({ + name: "Slack ops", + config: { + webhookUrl: "[REDACTED]", + hmacSecret: "[REDACTED]", + smtpPass: "[REDACTED]", + integrationKey: "[REDACTED]", + }, + }); + }); + + it("recurses into nested objects and arrays", () => { + const out = sanitizeInput({ + channels: [ + { name: "A", config: { hmacSecret: "x" } }, + { name: "B", config: { smtpPass: "y" } }, + ], + }); + expect(out).toEqual({ + channels: [ + { name: "A", config: { hmacSecret: "[REDACTED]" } }, + { name: "B", config: { smtpPass: "[REDACTED]" } }, + ], + }); + }); + + it("passes through non-sensitive primitives unchanged", () => { + const out = sanitizeInput({ id: "abc", count: 3, enabled: true }); + expect(out).toEqual({ id: "abc", count: 3, enabled: true }); + }); + + it("handles null and undefined", () => { + expect(sanitizeInput(null)).toBeNull(); + expect(sanitizeInput(undefined)).toBeUndefined(); + }); + + it("includes the new channel-secret keys in SENSITIVE_KEYS", () => { + expect(SENSITIVE_KEYS.has("hmacSecret")).toBe(true); + expect(SENSITIVE_KEYS.has("smtpPass")).toBe(true); + expect(SENSITIVE_KEYS.has("integrationKey")).toBe(true); + expect(SENSITIVE_KEYS.has("webhookUrl")).toBe(true); + }); +}); diff --git a/src/server/middleware/audit-sanitize.ts b/src/server/middleware/audit-sanitize.ts new file mode 100644 index 00000000..43a44a39 --- /dev/null +++ b/src/server/middleware/audit-sanitize.ts @@ -0,0 +1,51 @@ +export const SENSITIVE_KEYS = new Set([ + "password", "currentPassword", "newPassword", + "token", "secret", "key", "keyBase64", + "passwordHash", "httpsToken", "sshKey", + "aiApiKey", + "hmacSecret", "smtpPass", "integrationKey", "webhookUrl", +]); + +export function sanitizeInput(input: unknown): unknown { + if (input === null || input === undefined) return input; + if (typeof input !== "object") return input; + if (Array.isArray(input)) return input.map(sanitizeInput); + + const result: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (SENSITIVE_KEYS.has(key)) { + result[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeInput(value); + } else { + result[key] = value; + } + } + return result; +} + +export function computeDiff( + before: Record, + after: Record, +): Record | null { + const diff: Record = {}; + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of allKeys) { + if (key === "updatedAt" || key === "createdAt") continue; + if (SENSITIVE_KEYS.has(key)) { + if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) { + diff[key] = { old: "[REDACTED]", new: "[REDACTED]" }; + } + continue; + } + + const oldVal = before[key]; + const newVal = after[key]; + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + diff[key] = { old: oldVal, new: newVal }; + } + } + + return Object.keys(diff).length > 0 ? diff : null; +} diff --git a/src/server/middleware/audit.ts b/src/server/middleware/audit.ts index 58b41b3f..29965cb8 100644 --- a/src/server/middleware/audit.ts +++ b/src/server/middleware/audit.ts @@ -1,31 +1,9 @@ import { writeAuditLog } from "@/server/services/audit"; import { prisma } from "@/lib/prisma"; import { middleware } from "@/trpc/init"; +import { SENSITIVE_KEYS, sanitizeInput, computeDiff } from "./audit-sanitize"; -const SENSITIVE_KEYS = new Set([ - "password", "currentPassword", "newPassword", - "token", "secret", "key", "keyBase64", - "passwordHash", "httpsToken", "sshKey", - "aiApiKey", -]); - -function sanitizeInput(input: unknown): unknown { - if (input === null || input === undefined) return input; - if (typeof input !== "object") return input; - if (Array.isArray(input)) return input.map(sanitizeInput); - - const result: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (SENSITIVE_KEYS.has(key)) { - result[key] = "[REDACTED]"; - } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeInput(value); - } else { - result[key] = value; - } - } - return result; -} +export { SENSITIVE_KEYS, sanitizeInput, computeDiff }; /** * Resolve teamId from procedure input when not already in context. @@ -223,36 +201,6 @@ async function resolveEnvironmentId( return null; } -/** - * Compute a shallow diff between two entity snapshots. - * Returns only fields that changed, with { old, new } values. - */ -function computeDiff( - before: Record, - after: Record, -): Record | null { - const diff: Record = {}; - const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); - - for (const key of allKeys) { - if (key === "updatedAt" || key === "createdAt") continue; - if (SENSITIVE_KEYS.has(key)) { - if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) { - diff[key] = { old: "[REDACTED]", new: "[REDACTED]" }; - } - continue; - } - - const oldVal = before[key]; - const newVal = after[key]; - if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { - diff[key] = { old: oldVal, new: newVal }; - } - } - - return Object.keys(diff).length > 0 ? diff : null; -} - /** * Map entity types to their Prisma loaders for diff snapshots. * Sensitive fields excluded at query level.