forked from Yara724/api
339 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|