Files
yara724-api/src/fanavaran/fanavaran-audit.service.ts

339 lines
9.8 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError, type AxiosResponse } from "axios";
import { randomBytes } from "node:crypto";
import { Model, Types } from "mongoose";
import {
FanavaranAuditLog,
FanavaranAuditLogDocument,
FanavaranAuditStatus,
FanavaranAuditStep,
} from "./schema/fanavaran-audit-log.schema";
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
export interface RecordFanavaranAuditStepInput {
session: FanavaranAuditSession;
step: FanavaranAuditStep;
status: FanavaranAuditStatus;
requestUrl?: string;
requestMethod?: string;
httpStatus?: number;
requestHeaders?: Record<string, unknown>;
requestBody?: unknown;
requestMeta?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
responseMeta?: Record<string, unknown>;
errorMessage?: string;
errorDetails?: Record<string, unknown>;
durationMs?: number;
}
export interface FanavaranHttpExchange {
httpStatus?: number;
requestHeaders?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
}
@Injectable()
export class FanavaranAuditService {
private readonly logger = new Logger(FanavaranAuditService.name);
/** Max serialized chars kept for request/response bodies in audit docs. */
static readonly BODY_MAX_CHARS = 80_000;
private static readonly SENSITIVE_HEADER_KEYS = new Set([
"password",
"secret",
"authorization",
"authenticationtoken",
"apptoken",
"app-token",
"x-api-key",
"cookie",
"set-cookie",
]);
private static readonly SENSITIVE_BODY_KEYS = new Set([
"password",
"secret",
"authenticationtoken",
"apptoken",
"token",
"files",
]);
constructor(
@InjectModel(FanavaranAuditLog.name)
private readonly auditModel: Model<FanavaranAuditLogDocument>,
) {}
generateTrackingCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const suffix = randomBytes(3).toString("hex").toUpperCase();
return `FNV-${date}-${suffix}`;
}
maskNationalCode(nationalCode: string): string {
const trimmed = nationalCode.trim();
if (trimmed.length <= 4) {
return "****";
}
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`;
}
maskToken(value: string): string {
const trimmed = value.trim();
if (trimmed.length <= 8) {
return "****";
}
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`;
}
sanitizeHeaders(
headers?: Record<string, unknown> | null,
): Record<string, unknown> | undefined {
if (!headers || typeof headers !== "object") {
return undefined;
}
const out: Record<string, unknown> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawValue === undefined) continue;
const key = String(rawKey);
const lower = key.toLowerCase();
if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) {
out[key] =
typeof rawValue === "string"
? this.maskToken(rawValue)
: "***";
continue;
}
if (
typeof rawValue === "string" ||
typeof rawValue === "number" ||
typeof rawValue === "boolean" ||
rawValue === null
) {
out[key] = rawValue;
} else {
out[key] = String(rawValue);
}
}
return out;
}
sanitizeBody(body: unknown): unknown {
if (body === undefined) {
return undefined;
}
const masked = this.maskSensitiveDeep(body);
try {
const serialized = JSON.stringify(masked);
if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) {
return masked;
}
return {
_truncated: true,
maxChars: FanavaranAuditService.BODY_MAX_CHARS,
preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
};
} catch {
const asString = String(masked);
return asString.length <= FanavaranAuditService.BODY_MAX_CHARS
? asString
: {
_truncated: true,
preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
};
}
}
/**
* Extract request/response headers + body from an Axios response or Axios
* error (uses `config.headers` when present). Values are returned raw;
* `recordStep` applies sanitisation before persistence.
*/
captureAxiosExchange(
source: unknown,
overrideRequestHeaders?: Record<string, unknown>,
): FanavaranHttpExchange {
const toPlainHeaders = (
headers?: Record<string, unknown> | null,
): Record<string, unknown> | undefined => {
if (!headers || typeof headers !== "object") return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) continue;
out[key] =
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
value === null
? value
: String(value);
}
return out;
};
if (isAxiosError(source)) {
return {
httpStatus: source.response?.status,
requestHeaders: toPlainHeaders(
overrideRequestHeaders ??
(source.config?.headers as Record<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
source.response?.headers as Record<string, unknown> | undefined,
),
responseBody: source.response?.data,
};
}
const response = source as AxiosResponse | null;
if (
response &&
typeof response === "object" &&
"status" in response &&
"headers" in response
) {
return {
httpStatus: response.status,
requestHeaders: toPlainHeaders(
overrideRequestHeaders ??
(response.config?.headers as Record<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
response.headers as Record<string, unknown> | undefined,
),
responseBody: response.data,
};
}
return {
requestHeaders: toPlainHeaders(overrideRequestHeaders),
};
}
sanitizeErrorDetails(error: unknown): Record<string, unknown> {
if (isAxiosError(error)) {
const data = error.response?.data;
return {
type: "axios",
status: error.response?.status,
statusText: error.response?.statusText,
data: this.sanitizeBody(
typeof data === "object" && data !== null
? data
: typeof data === "string"
? data.slice(0, 2000)
: data,
),
};
}
if (error instanceof Error) {
return { type: "error", name: error.name, message: error.message };
}
return { type: "unknown", value: String(error) };
}
extractErrorMessage(error: unknown): string {
if (isAxiosError(error)) {
const data = error.response?.data as
| { Message?: string; message?: string }
| string
| undefined;
if (typeof data === "string") {
return data;
}
return (
data?.Message ||
data?.message ||
error.message ||
"Fanavaran request failed"
);
}
if (error instanceof Error) {
return error.message;
}
return "Fanavaran request failed";
}
formatErrorWithTrackingCode(message: string, trackingCode?: string): string {
if (!trackingCode) {
return message;
}
return `${message} (trackingCode: ${trackingCode})`;
}
async recordStep(input: RecordFanavaranAuditStepInput): Promise<void> {
try {
await this.auditModel.create({
trackingCode: input.session.trackingCode,
step: input.step,
status: input.status,
clientKey: input.session.clientKey,
source: input.session.source,
...(input.session.claimCaseId
? { claimCaseId: new Types.ObjectId(input.session.claimCaseId) }
: {}),
...(input.session.claimRequestId
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
: {}),
requestUrl: input.requestUrl,
requestMethod: input.requestMethod,
httpStatus: input.httpStatus,
requestHeaders: this.sanitizeHeaders(input.requestHeaders),
requestBody: this.sanitizeBody(input.requestBody),
requestMeta: input.requestMeta,
responseHeaders: this.sanitizeHeaders(input.responseHeaders),
responseBody: this.sanitizeBody(input.responseBody),
responseMeta: input.responseMeta,
errorMessage: input.errorMessage,
errorDetails: input.errorDetails,
durationMs: input.durationMs,
});
} catch (error) {
this.logger.error(
`Failed to persist Fanavaran audit step ${input.step} (${input.status})`,
error,
);
}
}
async findByTrackingCode(trackingCode: string) {
return this.auditModel
.find({ trackingCode })
.sort({ createdAt: 1 })
.lean()
.exec();
}
private maskSensitiveDeep(value: unknown, depth = 0): unknown {
if (depth > 8) {
return "[max-depth]";
}
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => this.maskSensitiveDeep(item, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(
value as Record<string, unknown>,
)) {
if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) {
out[key] =
typeof child === "string" ? this.maskToken(child) : "[redacted]";
continue;
}
out[key] = this.maskSensitiveDeep(child, depth + 1);
}
return out;
}
return value;
}
}