Files
Nexus/packages/api/src/lib/audit.ts
T
HartmutandClaude Opus 4.7 3d89d7d8eb security: redact sensitive fields in audit DB entries (#46)
createAuditEntry now deep-walks before/after/metadata and replaces
values of password, newPassword, currentPassword, passwordHash, token,
accessToken, refreshToken, sessionToken, apiKey, authorization, cookie,
secret, totpSecret, backupCode(s) with "[REDACTED]" before the JSONB
write.

The pino logger already redacts these paths for stdout (see
lib/logger.ts), but DB writes had no equivalent guard — the AI chat
loop at assistant-chat-loop.ts:265 blindly stores parsedArgs from tool
calls (e.g. set_user_password, create_user) into the AuditLog table.

Matching is case-insensitive; nested objects and arrays are recursed to
a depth of 8. Diffs are computed post-redaction so UPDATE entries that
only changed a sensitive field are correctly collapsed to no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:25:15 +02:00

213 lines
6.1 KiB
TypeScript

import type { PrismaClient, Prisma } from "@capakraken/db";
import { logger } from "./logger.js";
type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT";
type AuditSource = "ui" | "api" | "ai" | "import" | "cron";
interface CreateAuditEntryParams {
db: PrismaClient;
entityType: string;
entityId: string;
entityName?: string;
action: AuditAction;
userId?: string;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
source?: AuditSource;
summary?: string;
metadata?: Record<string, unknown>;
}
const INTERNAL_FIELDS = new Set(["id", "createdAt", "updatedAt"]);
// Field names whose values are never safe to persist into the audit log.
// Matching is case-insensitive and applied at every level of the object graph.
const SENSITIVE_FIELD_NAMES = new Set([
"password",
"newpassword",
"currentpassword",
"oldpassword",
"passwordhash",
"passwordconfirmation",
"confirmpassword",
"token",
"accesstoken",
"refreshtoken",
"sessiontoken",
"apikey",
"authorization",
"cookie",
"secret",
"totpsecret",
"backupcode",
"backupcodes",
]);
const REDACTED_PLACEHOLDER = "[REDACTED]";
const MAX_REDACT_DEPTH = 8;
/**
* Recursively strip values of fields whose names appear in SENSITIVE_FIELD_NAMES.
* Used to prevent password/token leaks into the audit log JSONB column.
*
* The pino logger has its own redact config for stdout; this function is the
* DB-write equivalent.
*/
function redactSensitive(value: unknown, depth: number = 0): unknown {
if (depth > MAX_REDACT_DEPTH) return value;
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map((v) => redactSensitive(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_FIELD_NAMES.has(k.toLowerCase())) {
out[k] = REDACTED_PLACEHOLDER;
} else {
out[k] = redactSensitive(v, depth + 1);
}
}
return out;
}
return value;
}
export const __test__ = { redactSensitive, SENSITIVE_FIELD_NAMES };
/**
* Compare two snapshots and return only the changed fields.
* Skips internal fields (id, createdAt, updatedAt).
* Uses JSON.stringify for nested object comparison.
*/
export function computeDiff(
before: Record<string, unknown>,
after: Record<string, unknown>,
): Record<string, { old: unknown; new: unknown }> {
const diff: Record<string, { old: unknown; new: unknown }> = {};
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
if (INTERNAL_FIELDS.has(key)) continue;
const oldVal = before[key];
const newVal = after[key];
// Compare by JSON serialization to handle nested objects/arrays
const oldStr = JSON.stringify(oldVal) ?? "undefined";
const newStr = JSON.stringify(newVal) ?? "undefined";
if (oldStr !== newStr) {
diff[key] = { old: oldVal, new: newVal };
}
}
return diff;
}
/**
* Auto-generate a human-readable summary from the action and diff.
*/
export function generateSummary(
action: string,
entityType: string,
diff?: Record<string, { old: unknown; new: unknown }>,
): string {
switch (action) {
case "CREATE":
return `Created ${entityType}`;
case "DELETE":
return `Deleted ${entityType}`;
case "SHIFT":
return `Shifted ${entityType}`;
case "IMPORT":
return `Imported ${entityType}`;
case "UPDATE": {
if (!diff || Object.keys(diff).length === 0) {
return `Updated ${entityType}`;
}
const fields = Object.keys(diff);
if (fields.length <= 3) {
return `Updated ${fields.join(", ")}`;
}
return `Updated ${fields.slice(0, 3).join(", ")} and ${fields.length - 3} more`;
}
default:
return `${action} ${entityType}`;
}
}
/**
* Create an audit log entry. Fire-and-forget — errors are logged but never thrown.
*
* If both `before` and `after` are provided, a diff is computed automatically.
* If no `summary` is given, one is generated from the action and diff.
*/
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
try {
const {
db,
entityType,
entityId,
entityName,
action,
userId,
before,
after,
source,
metadata,
} = params;
const auditLog = (db as Partial<PrismaClient>).auditLog;
if (!auditLog || typeof auditLog.create !== "function") {
return;
}
// Redact sensitive field values before anything else — diffs and summaries
// must all be derived from already-sanitised snapshots.
const safeBefore = before ? (redactSensitive(before) as Record<string, unknown>) : undefined;
const safeAfter = after ? (redactSensitive(after) as Record<string, unknown>) : undefined;
const safeMetadata = metadata
? (redactSensitive(metadata) as Record<string, unknown>)
: undefined;
// Compute diff if both snapshots are available
const diff = safeBefore && safeAfter ? computeDiff(safeBefore, safeAfter) : undefined;
// Skip UPDATE entries where nothing actually changed
if (action === "UPDATE" && diff && Object.keys(diff).length === 0) {
return;
}
// Auto-generate summary if not provided
const summary = params.summary ?? generateSummary(action, entityType, diff);
// Build the changes JSONB payload
const changes: Record<string, unknown> = {};
if (safeBefore) changes.before = safeBefore;
if (safeAfter) changes.after = safeAfter;
if (diff) changes.diff = diff;
if (safeMetadata) changes.metadata = safeMetadata;
await auditLog.create({
data: {
entityType,
entityId,
action,
userId: userId ?? null,
changes: changes as unknown as Prisma.InputJsonValue,
source: source ?? null,
entityName: entityName ?? null,
summary,
},
});
} catch (error) {
// Fire-and-forget: log but never propagate
logger.error(
{ err: error, entityType: params.entityType, entityId: params.entityId },
"Failed to create audit entry",
);
}
}