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>
213 lines
6.1 KiB
TypeScript
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",
|
|
);
|
|
}
|
|
}
|