mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 11:29:56 +03:00
261 lines
6.3 KiB
TypeScript
261 lines
6.3 KiB
TypeScript
/**
|
|
* Utility functions for computing diffs between objects for audit logging
|
|
*/
|
|
|
|
export interface FieldChange {
|
|
field: string;
|
|
oldValue: any;
|
|
newValue: any;
|
|
}
|
|
|
|
export interface AuditDiff {
|
|
changes: FieldChange[];
|
|
}
|
|
|
|
/**
|
|
* Fields that should never be included in audit diffs (sensitive data)
|
|
*/
|
|
const SENSITIVE_FIELDS = new Set([
|
|
'password',
|
|
'sshPrivateKey',
|
|
'sshPassphrase',
|
|
'tlsKey',
|
|
'tlsCert',
|
|
'tlsCa',
|
|
'hawserToken',
|
|
'token',
|
|
'secret',
|
|
'apiKey'
|
|
]);
|
|
|
|
/**
|
|
* Fields that should be shown as masked if changed
|
|
*/
|
|
const MASKED_FIELDS = new Set([
|
|
'password',
|
|
'sshPrivateKey',
|
|
'sshPassphrase',
|
|
'tlsKey',
|
|
'hawserToken',
|
|
'token',
|
|
'secret',
|
|
'apiKey'
|
|
]);
|
|
|
|
/**
|
|
* Fields that should be skipped entirely (internal timestamps, etc.)
|
|
*/
|
|
const SKIP_FIELDS = new Set([
|
|
'updatedAt',
|
|
'createdAt',
|
|
'id'
|
|
]);
|
|
|
|
/**
|
|
* Compute the diff between two objects for audit logging
|
|
* Returns only the fields that have changed
|
|
*/
|
|
export function computeAuditDiff(
|
|
oldObj: Record<string, any> | null | undefined,
|
|
newObj: Record<string, any> | null | undefined,
|
|
options: {
|
|
includeFields?: string[];
|
|
excludeFields?: string[];
|
|
} = {}
|
|
): AuditDiff | null {
|
|
if (!oldObj || !newObj) {
|
|
return null;
|
|
}
|
|
|
|
const changes: FieldChange[] = [];
|
|
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
|
|
for (const key of allKeys) {
|
|
// Skip internal fields
|
|
if (SKIP_FIELDS.has(key)) continue;
|
|
|
|
// Skip if excluded
|
|
if (options.excludeFields?.includes(key)) continue;
|
|
|
|
// Skip if includeFields specified and key not in it
|
|
if (options.includeFields && !options.includeFields.includes(key)) continue;
|
|
|
|
const oldVal = oldObj[key];
|
|
const newVal = newObj[key];
|
|
|
|
// Skip undefined new values (not provided in update)
|
|
if (newVal === undefined) continue;
|
|
|
|
// Check if values are different
|
|
if (!isEqual(oldVal, newVal)) {
|
|
// Handle sensitive fields - show as masked
|
|
if (MASKED_FIELDS.has(key)) {
|
|
// Only show change if the masked field actually changed
|
|
const oldHasValue = oldVal !== null && oldVal !== undefined && oldVal !== '';
|
|
const newHasValue = newVal !== null && newVal !== undefined && newVal !== '';
|
|
|
|
if (oldHasValue !== newHasValue || (oldHasValue && newHasValue)) {
|
|
changes.push({
|
|
field: key,
|
|
oldValue: oldHasValue ? '••••••••' : null,
|
|
newValue: newHasValue ? '••••••••' : null
|
|
});
|
|
}
|
|
} else if (SENSITIVE_FIELDS.has(key)) {
|
|
// Skip entirely for other sensitive fields
|
|
continue;
|
|
} else {
|
|
changes.push({
|
|
field: key,
|
|
oldValue: formatValue(oldVal),
|
|
newValue: formatValue(newVal)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return { changes };
|
|
}
|
|
|
|
/**
|
|
* Deep equality check for values
|
|
*/
|
|
function isEqual(a: any, b: any): boolean {
|
|
// Handle null/undefined
|
|
if (a === b) return true;
|
|
if (a === null || b === null) return false;
|
|
if (a === undefined || b === undefined) return false;
|
|
|
|
// Handle arrays
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
if (a.length !== b.length) return false;
|
|
return a.every((val, idx) => isEqual(val, b[idx]));
|
|
}
|
|
|
|
// Handle objects
|
|
if (typeof a === 'object' && typeof b === 'object') {
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
if (keysA.length !== keysB.length) return false;
|
|
return keysA.every(key => isEqual(a[key], b[key]));
|
|
}
|
|
|
|
// Primitive comparison
|
|
return a === b;
|
|
}
|
|
|
|
/**
|
|
* Format a value for display in the diff
|
|
*/
|
|
function formatValue(val: any): any {
|
|
if (val === null || val === undefined) {
|
|
return null;
|
|
}
|
|
|
|
// Truncate long strings
|
|
if (typeof val === 'string' && val.length > 200) {
|
|
return val.substring(0, 200) + '...';
|
|
}
|
|
|
|
// Handle arrays - show count if too many items
|
|
if (Array.isArray(val)) {
|
|
if (val.length > 10) {
|
|
return `[${val.length} items]`;
|
|
}
|
|
return val.map(formatValue);
|
|
}
|
|
|
|
// Handle objects - show keys if too complex
|
|
if (typeof val === 'object') {
|
|
const keys = Object.keys(val);
|
|
if (keys.length > 10) {
|
|
return `{${keys.length} properties}`;
|
|
}
|
|
const formatted: Record<string, any> = {};
|
|
for (const key of keys) {
|
|
formatted[key] = formatValue(val[key]);
|
|
}
|
|
return formatted;
|
|
}
|
|
|
|
return val;
|
|
}
|
|
|
|
/**
|
|
* Deep-diff two objects recursively, returning all paths that differ.
|
|
* Used for comparing container inspect snapshots before and after recreation.
|
|
*/
|
|
export function deepDiff(a: any, b: any, path = ''): string[] {
|
|
const diffs: string[] = [];
|
|
|
|
if (a === b) return diffs;
|
|
if (a === null || b === null || typeof a !== typeof b) {
|
|
diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
|
return diffs;
|
|
}
|
|
if (typeof a !== 'object') {
|
|
if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
|
return diffs;
|
|
}
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
const aStr = JSON.stringify(a);
|
|
const bStr = JSON.stringify(b);
|
|
if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`);
|
|
return diffs;
|
|
}
|
|
|
|
const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
|
|
for (const key of allKeys) {
|
|
const childPath = path ? `${path}.${key}` : key;
|
|
if (!(key in a)) {
|
|
diffs.push(`${childPath}: <missing> → ${JSON.stringify(b[key])}`);
|
|
} else if (!(key in b)) {
|
|
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
|
} else {
|
|
diffs.push(...deepDiff(a[key], b[key], childPath));
|
|
}
|
|
}
|
|
|
|
return diffs;
|
|
}
|
|
|
|
/**
|
|
* Format field name for display (camelCase to Title Case)
|
|
*/
|
|
export function formatFieldName(field: string): string {
|
|
// Handle special cases
|
|
const specialCases: Record<string, string> = {
|
|
'tlsCa': 'TLS CA',
|
|
'tlsCert': 'TLS certificate',
|
|
'tlsKey': 'TLS key',
|
|
'tlsSkipVerify': 'Skip TLS verification',
|
|
'sshPrivateKey': 'SSH private key',
|
|
'sshPassphrase': 'SSH passphrase',
|
|
'envVars': 'Environment variables',
|
|
'isDefault': 'Default',
|
|
'ipAddress': 'IP address',
|
|
'authType': 'Auth type',
|
|
'eventTypes': 'Event types',
|
|
'hawserToken': 'Hawser token',
|
|
'connectionType': 'Connection type',
|
|
'socketPath': 'Socket path',
|
|
'collectActivity': 'Collect activity',
|
|
'collectMetrics': 'Collect metrics',
|
|
'highlightChanges': 'Highlight changes'
|
|
};
|
|
|
|
if (specialCases[field]) {
|
|
return specialCases[field];
|
|
}
|
|
|
|
// Convert camelCase to Title Case with spaces
|
|
return field
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/^./, str => str.toUpperCase())
|
|
.trim();
|
|
}
|