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>
This commit is contained in:
@@ -20,6 +20,61 @@ interface CreateAuditEntryParams {
|
||||
|
||||
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).
|
||||
@@ -91,15 +146,34 @@ export function generateSummary(
|
||||
*/
|
||||
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
|
||||
try {
|
||||
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
|
||||
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 = before && after ? computeDiff(before, after) : undefined;
|
||||
const diff = safeBefore && safeAfter ? computeDiff(safeBefore, safeAfter) : undefined;
|
||||
|
||||
// Skip UPDATE entries where nothing actually changed
|
||||
if (action === "UPDATE" && diff && Object.keys(diff).length === 0) {
|
||||
@@ -111,10 +185,10 @@ export async function createAuditEntry(params: CreateAuditEntryParams): Promise<
|
||||
|
||||
// Build the changes JSONB payload
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (before) changes.before = before;
|
||||
if (after) changes.after = after;
|
||||
if (safeBefore) changes.before = safeBefore;
|
||||
if (safeAfter) changes.after = safeAfter;
|
||||
if (diff) changes.diff = diff;
|
||||
if (metadata) changes.metadata = metadata;
|
||||
if (safeMetadata) changes.metadata = safeMetadata;
|
||||
|
||||
await auditLog.create({
|
||||
data: {
|
||||
@@ -130,6 +204,9 @@ export async function createAuditEntry(params: CreateAuditEntryParams): Promise<
|
||||
});
|
||||
} catch (error) {
|
||||
// Fire-and-forget: log but never propagate
|
||||
logger.error({ err: error, entityType: params.entityType, entityId: params.entityId }, "Failed to create audit entry");
|
||||
logger.error(
|
||||
{ err: error, entityType: params.entityType, entityId: params.entityId },
|
||||
"Failed to create audit entry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user