fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to Sheet side=right so every detail-preview surface uses the same primitive. Document the doctrine: Sheet for side panels on both desktop and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX (currently just MoreSheet). Closes ui/ux M11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,26 +79,101 @@ export interface AuditLogParams {
|
||||
source?: AuditSource;
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
|
||||
// Lower-cased key fragments. A metadata key is masked if any fragment is
|
||||
// contained as a substring after lowercase + snake/kebab normalization.
|
||||
// Substring match catches `recipientEmail`, `sent_to_email`, `userEmail`,
|
||||
// `attempted_email`, `from_address`, `phone_number`, `passwordHash`, etc.
|
||||
const SENSITIVE_KEY_FRAGMENTS = [
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'token',
|
||||
'credentials',
|
||||
'secret',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'address', // physical/mailing addresses
|
||||
'dob',
|
||||
'date_of_birth',
|
||||
'birthdate',
|
||||
'tax_id',
|
||||
'taxid',
|
||||
'national_id',
|
||||
'ssn',
|
||||
'passport',
|
||||
'iban',
|
||||
'card_number',
|
||||
'cvv',
|
||||
'recipient', // e.g. recipientEmail catches the parent too — preserves intent
|
||||
'first_name',
|
||||
'last_name',
|
||||
'full_name',
|
||||
'fullname',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const k = key.toLowerCase().replace(/[-]/g, '_');
|
||||
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
|
||||
}
|
||||
|
||||
function maskString(val: string): string {
|
||||
return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
|
||||
}
|
||||
|
||||
function maskValue(value: unknown, depth: number): unknown {
|
||||
if (depth > 4) return '[depth-limit]';
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'string') return maskString(value);
|
||||
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
// Recurse into nested object — only mask keys that themselves look
|
||||
// sensitive. Parents stay traversable.
|
||||
return maskObject(value as Record<string, unknown>, depth + 1);
|
||||
}
|
||||
// Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
|
||||
// are passed through unchanged. The original contract was "only mask
|
||||
// strings" — a number at an `email` key is a type error upstream and
|
||||
// shouldn't be silently replaced with `***`.
|
||||
return value;
|
||||
}
|
||||
|
||||
function maskObject(data: Record<string, unknown>, depth: number): Record<string, unknown> {
|
||||
if (depth > 4) return { _truncated: '[depth-limit]' };
|
||||
const masked: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
masked[key] = maskValue(value, depth + 1);
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
masked[key] = maskObject(value as Record<string, unknown>, depth + 1);
|
||||
} else if (Array.isArray(value)) {
|
||||
masked[key] = value.map((v) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? maskObject(v as Record<string, unknown>, depth + 1)
|
||||
: v,
|
||||
);
|
||||
} else {
|
||||
masked[key] = value;
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks sensitive field values to prevent PII or secrets from being stored
|
||||
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
|
||||
*
|
||||
* Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars.
|
||||
* Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
|
||||
* the inner value too.
|
||||
*/
|
||||
export function maskSensitiveFields(
|
||||
data?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!data) return undefined;
|
||||
const masked = { ...data };
|
||||
for (const key of Object.keys(masked)) {
|
||||
if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') {
|
||||
const val = masked[key] as string;
|
||||
masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
return maskObject(data, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user