Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/settings-sidebar-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Sparkles,
Activity,
Send,
Webhook,
} from "lucide-react";

export const settingsNavGroups = [
Expand All @@ -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 },
],
},
Expand Down
70 changes: 70 additions & 0 deletions src/server/middleware/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions src/server/middleware/audit-sanitize.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
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<string, unknown>,
after: Record<string, unknown>,
): Record<string, { old: unknown; new: unknown }> | null {
const diff: Record<string, { old: unknown; new: unknown }> = {};
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;
}
56 changes: 2 additions & 54 deletions src/server/middleware/audit.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
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.
Expand Down Expand Up @@ -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<string, unknown>,
after: Record<string, unknown>,
): Record<string, { old: unknown; new: unknown }> | null {
const diff: Record<string, { old: unknown; new: unknown }> = {};
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.
Expand Down
Loading