mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- 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.
196 lines
6.3 KiB
TypeScript
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}`;
|
|
}
|