Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/jobs/sanctionSyncJob.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { sanctionService } from "../services/sanctionService";
import {
sanctionsListLastUpdateTimestamp,
sanctionsListRecordCount,
sanctionsSyncFailuresTotal,
} from "../utils/metrics";

/**
* Background job to fetch and sync global sanction lists.
* Runs daily to ensure AML screening is based on the latest data.
*/
export async function runSanctionSyncJob(): Promise<void> {
console.log("[sanction-sync] Starting daily sanction list synchronization...");

try {
const updates = await sanctionService.fetchSanctionUpdates();
console.log(`[sanction-sync] Fetched ${updates.length} entities from global lists.`);

await sanctionService.updateSanctionList(updates);
console.log("[sanction-sync] Successfully updated internal sanction blacklist.");

sanctionsListLastUpdateTimestamp.set(Date.now() / 1000);
sanctionsListRecordCount.set(updates.length);
} catch (error) {
sanctionsSyncFailuresTotal.inc();
console.error("[sanction-sync] Critical failure during sanction sync:", error);
throw error;
}
Expand Down
7 changes: 7 additions & 0 deletions src/jobs/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../config/env";
import { runIndexReindexJob } from "./indexReindexJob";
import { runSanctionSyncJob } from "./sanctionSyncJob";
import { runTravelRuleAuditReportJob } from "./travelRuleAuditReportJob";
import { startNotificationWorker } from "../workers/notificationWorker";

interface JobConfig {
Expand Down Expand Up @@ -150,6 +151,12 @@ const JOBS: JobConfig[] = [
schedule: process.env.DATABASE_BACKUP_VERIFY_CRON || "0 3 * * *",
handler: runDatabaseBackupVerifyJob,
},
{
name: "travel-rule-audit-report",
// 1st of every month at midnight - summarizes prior month's Travel Rule coverage
schedule: process.env.TRAVEL_RULE_AUDIT_REPORT_CRON || "0 0 1 * *",
handler: runTravelRuleAuditReportJob,
},
];

async function runJob(job: JobConfig): Promise<void> {
Expand Down
30 changes: 30 additions & 0 deletions src/jobs/travelRuleAuditReportJob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
generateTravelRuleAuditReport,
previousMonthRange,
} from "../reports/travelRuleAuditReport";

/**
* Travel Rule Audit Report Job
* Schedule: 1st of every month at midnight (0 0 1 * *)
* Generates the previous month's Travel Rule coverage summary for
* regulatory review. Full PDF/CSV exports are available on-demand via
* GET /api/v1/compliance/travel-rule/audit-report.{csv,pdf}
*/
export async function runTravelRuleAuditReportJob(): Promise<void> {
const { start, end } = previousMonthRange(new Date());
const report = await generateTravelRuleAuditReport(start, end);

console.log(
`[travel-rule-audit] ${start.toISOString().slice(0, 7)}: ` +
`${report.eligibleTransactionCount} eligible, ` +
`${report.capturedRecordCount} captured, ` +
`${report.coveragePercentage}% coverage, ` +
`${report.missedTransactions.length} missed`,
);

if (report.missedTransactions.length > 0) {
console.warn(
`[travel-rule-audit] ${report.missedTransactions.length} transaction(s) missing Travel Rule data`,
);
}
}
5 changes: 3 additions & 2 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ERROR_CODES, getHttpStatus } from "../constants/errorCodes";
import { getLocalizedMessage } from "../locales/messages";
import { resolveLocale, resolveLocaleFromRequest } from "../utils/i18n";
import logger from "../utils/logger";
import { maskPii } from "../utils/piiMask";

/**
* Extended Error interface with error-specific properties.
Expand Down Expand Up @@ -182,7 +183,7 @@ export const errorHandler = (
statusCode,
}, 'Request Error');

const details = extractLegacyDetails(err);
const details = maskPii(extractLegacyDetails(err)) as Record<string, unknown>;
const body: ErrorResponse & { statusCode: number; error?: string } = {
code: errorCode,
message: localizedMessage,
Expand All @@ -196,7 +197,7 @@ export const errorHandler = (
if (details && typeof details === "object" && typeof details.error === "string") {
body.error = details.error;
} else if (err.message) {
body.error = err.message;
body.error = maskPii(err.message) as string;
} else {
body.error = englishMessage;
}
Expand Down
122 changes: 90 additions & 32 deletions src/middleware/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,112 @@
import { Request, Response, NextFunction } from "express";
import { pool } from "../config/database";
import { redisClient } from "../config/redis";
import { createHash } from "crypto";
import { getCurrentRequestIp } from "../services/loginAnomaly";

declare module "express-serve-static-core" {
interface Request {
isNewDevice?: boolean;
}
}

/** Number of new-device fingerprints within the window before step-up auth is required. */
const MISMATCH_STEP_UP_THRESHOLD = 3;
const MISMATCH_WINDOW_SECONDS = 24 * 60 * 60;

export function hashString(value: string | null | undefined): string {
const v = value ?? "";
return createHash("sha256").update(v, "utf8").digest("hex");
}

function headerValue(req: Request, name: string): string {
const value = req.headers[name];
return (Array.isArray(value) ? value[0] : value) ?? "";
}

/** Extracts the negotiated TLS cipher name, or "" for a plaintext connection. */
function extractTlsCipher(req: Request): string {
const socket = req.socket as unknown as { getCipher?: () => { name: string } | undefined };
return socket.getCipher?.()?.name ?? "";
}

// Utility to extract fingerprint from headers/params and return a hashed value
export function extractFingerprint(req: Request): string {
const userAgent = Array.isArray(req.headers["user-agent"])
? req.headers["user-agent"][0]
: (req.headers["user-agent"] ?? "");
const acceptLanguage = Array.isArray(req.headers["accept-language"])
? req.headers["accept-language"][0]
: (req.headers["accept-language"] ?? "");
const userAgent = headerValue(req, "user-agent");
const acceptLanguage = headerValue(req, "accept-language");
const deviceId =
(Array.isArray(req.headers["x-device-id"])
? req.headers["x-device-id"][0]
: req.headers["x-device-id"]) ||
(req.query?.deviceId as string) ||
"";

// Hash the combined fingerprint parts to avoid storing raw UA / language
const raw = `${userAgent}|${acceptLanguage}|${deviceId}`;
headerValue(req, "x-device-id") || (req.query?.deviceId as string) || "";
const ipAddress = getCurrentRequestIp(req) ?? "";
const tlsCipher = extractTlsCipher(req);

// Hash the combined fingerprint parts to avoid storing raw UA / IP / language
const raw = `${userAgent}|${acceptLanguage}|${deviceId}|${ipAddress}|${tlsCipher}`;
return hashString(raw);
}

export interface DeviceFingerprintCheck {
fingerprint: string;
isNewDevice: boolean;
/** True once repeated new-device mismatches within the window exceed the threshold. */
requiresStepUp: boolean;
}

/**
* Records the device fingerprint for a login/authenticated request and
* reports whether this is a device the user hasn't used before.
*
* Works across login sessions — every call for a given user is checked
* against all previously seen fingerprints for that user, not just the
* current session. Repeated new-device mismatches within a 24h window
* flag `requiresStepUp` so the caller can demand additional verification
* (e.g. 2FA) before completing authentication.
*/
export async function recordDeviceFingerprint(
userId: string,
req: Request,
): Promise<DeviceFingerprintCheck> {
const fingerprint = extractFingerprint(req);

const existing = await pool.query(
"SELECT id FROM device_fingerprints WHERE user_id = $1 AND fingerprint = $2",
[userId, fingerprint],
);

const isNewDevice = existing.rows.length === 0;

if (!isNewDevice) {
return { fingerprint, isNewDevice: false, requiresStepUp: false };
}

await pool.query(
"INSERT INTO device_fingerprints (user_id, fingerprint) VALUES ($1, $2)",
[userId, fingerprint],
);

console.warn(
JSON.stringify({
event: "device_fingerprint_changed",
userId,
fingerprint,
timestamp: new Date().toISOString(),
}),
);

let requiresStepUp = false;
try {
const mismatchKey = `fingerprint:mismatches:${userId}`;
const count = await redisClient.incr(mismatchKey);
if (count === 1) {
await redisClient.expire(mismatchKey, MISMATCH_WINDOW_SECONDS);
}
requiresStepUp = count >= MISMATCH_STEP_UP_THRESHOLD;
} catch (error) {
console.error("[fingerprint] Failed to track mismatch count:", error);
}

return { fingerprint, isNewDevice: true, requiresStepUp };
}

// Middleware to collect and compare device fingerprints
export async function fingerprintMiddleware(
req: Request,
Expand All @@ -41,24 +115,8 @@ export async function fingerprintMiddleware(
) {
const userId = (req.body as any)?.userId || (req as any).user?.id; // Adjust as per your auth
if (!userId) return next();
const fingerprint = extractFingerprint(req);

// Check fingerprint history (store hashed fingerprint)
const result = await pool.query(
"SELECT * FROM device_fingerprints WHERE user_id = $1 AND fingerprint = $2",
[userId, fingerprint],
);

if (result.rows.length === 0) {
// New device detected
await pool.query(
"INSERT INTO device_fingerprints (user_id, fingerprint) VALUES ($1, $2)",
[userId, fingerprint],
);
// TODO: Trigger email alert to user
req.isNewDevice = true;
} else {
req.isNewDevice = false;
}
const result = await recordDeviceFingerprint(userId, req);
req.isNewDevice = result.isNewDevice;
next();
}
Loading
Loading