Files
edr-platform/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

196 lines
6.3 KiB
TypeScript

/**
* Redaction and shrinking for anything copied into `audit_logs.request`.
*
* This matters more here than in a typical audit log. `main.ts` raises the JSON
* body ceiling to 100MB so contract signing can post a signature AND a company
* stamp as base64 in one request. Copying a body like that verbatim would put
* both a credential-grade artefact and a 100MB blob into the audit table, on
* the write path of every audited endpoint.
*/
const REDACTED = '[REDACTED]';
/**
* Substring-matched against lower-cased key names, so `newPassword`,
* `otpCode` and `x-authorization` are all caught without enumerating variants.
*
* `signature` and `stamp` are here because contract signing posts both as
* base64 — they are simultaneously the largest and the most sensitive fields
* this API accepts.
*/
const SENSITIVE_KEY_PATTERNS = [
'password',
'otp',
'token',
'secret',
'pin',
'authorization',
'signature',
'stamp',
'apikey',
'api_key',
'credential',
'ssn',
];
/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */
const MAX_REQUEST_BYTES = 64 * 1024;
/** Depth guard: deep nesting is never worth the recursion cost here. */
const MAX_DEPTH = 6;
/** Long strings (base64 blobs) are truncated rather than stored whole. */
const MAX_STRING_LENGTH = 2_000;
function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern));
}
/**
* Multer file shape, reduced to a descriptor. The buffer is never stored —
* Postgres is the wrong home for file bytes, and `audit_logs` doubly so.
*/
function isMulterFile(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.originalname === 'string' &&
(typeof candidate.mimetype === 'string' || typeof candidate.size === 'number')
);
}
function describeFile(value: Record<string, unknown>): Record<string, unknown> {
return {
__file: true,
originalName: value.originalname ?? null,
mimeType: value.mimetype ?? null,
size: typeof value.size === 'number' ? value.size : null,
fieldName: value.fieldname ?? null,
};
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) return value ?? null;
if (typeof value === 'string') {
return value.length > MAX_STRING_LENGTH
? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]`
: value;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (value instanceof Date) return value.toISOString();
// Buffers are file bytes by definition — never persisted, only described.
if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length };
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
if (Array.isArray(value)) {
// Cap array length: bulk endpoints post large collections.
const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1));
if (value.length > 50) capped.push(`…[${value.length - 50} more items]`);
return capped;
}
if (typeof value === 'object') {
if (isMulterFile(value)) return describeFile(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1);
}
return out;
}
// Functions, symbols and anything else are not audit data.
return null;
}
/**
* Sanitize a request body (or query object) for storage.
*
* Returns null when there is nothing worth keeping, so empty bodies do not
* occupy jsonb rows.
*/
export function sanitizeRequestPayload(
body: unknown,
files?: unknown,
): Record<string, unknown> | null {
const payload: Record<string, unknown> = {};
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
const sanitizedBody = sanitizeValue(body, 0);
if (sanitizedBody && typeof sanitizedBody === 'object') {
Object.assign(payload, sanitizedBody as Record<string, unknown>);
}
}
// Multer puts uploads on `req.files`, outside `req.body`, so they are folded
// in explicitly — otherwise a pure-upload request records an empty payload.
if (files) {
const sanitizedFiles = sanitizeValue(files, 0);
if (
sanitizedFiles &&
(Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object')
) {
const hasEntries = Array.isArray(sanitizedFiles)
? sanitizedFiles.length > 0
: Object.keys(sanitizedFiles as object).length > 0;
if (hasEntries) payload.__uploads = sanitizedFiles;
}
}
if (Object.keys(payload).length === 0) return null;
// Final size guard. A body can stay under every per-field cap and still be
// enormous in aggregate, so the serialized form is measured before storing.
const serialized = JSON.stringify(payload);
if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) {
return {
__truncated: true,
reason: 'Payload exceeded the audit size limit',
bytes: Buffer.byteLength(serialized, 'utf8'),
keys: Object.keys(payload).slice(0, 50),
};
}
return payload;
}
/**
* Rebuild a URL with sensitive query values redacted.
*
* `url` is stored with its full query string, and query strings are a common
* place for one-time tokens and signed links, so the same deny-list that
* protects the body is applied to the query.
*/
export function redactUrlQuery(url: string): string {
const queryIndex = url.indexOf('?');
if (queryIndex === -1) return url;
const path = url.slice(0, queryIndex);
const query = url.slice(queryIndex + 1);
if (!query) return path;
const redacted = query
.split('&')
.map((pair) => {
const eq = pair.indexOf('=');
if (eq === -1) return pair;
const key = pair.slice(0, eq);
// Keys arrive percent-encoded; decode before matching so `api%2Dkey`
// is not treated as harmless.
let decodedKey = key;
try {
decodedKey = decodeURIComponent(key);
} catch {
/* malformed encoding — fall back to the raw key */
}
return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair;
})
.join('&');
return `${path}?${redacted}`;
}