mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
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.
This commit is contained in:
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal file
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user