Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,15 @@ verify_output.txt
# ── Lock files (keep pnpm-lock.yaml, ignore others) ───────────────
# Root-level package-lock from accidental npm installs
package-lock.json!.env.sample

# ── Additional ignores ────────────────────────────────────────────
**/__snapshots__/
**/*.snap
*.swp
*.swo
*.tmp
*.temp
logs/
**/logs/
.env.local
.env.*.local
56 changes: 56 additions & 0 deletions backend/docs/alerts/fraud-detection-engine.rules.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
groups:
- name: fraud_detection_engine
rules:
- alert: FraudDetectionEngineHighAlertRate
expr: rate(fraud_detection_alerts_fired_total[5m]) > 10
for: 2m
labels:
severity: critical
annotations:
summary: "High fraud alert rate detected"
description: "Fraud Detection Engine is firing more than 10 alerts/sec over 5 minutes for merchant {{ $labels.merchant_id }}."

- alert: FraudDetectionEngineDegraded
expr: fraud_detection_health_status{component="engine"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Fraud Detection Engine is degraded"
description: "The Fraud Detection Engine health status is degraded."

- alert: FraudDetectionCacheNearCapacity
expr: fraud_detection_cache_health{metric_type="size"} / fraud_detection_cache_health{metric_type="max_entries"} > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "Fraud Detection cache near capacity"
description: "Fraud Detection Engine cache is at over 90% capacity."

- alert: FraudDetectionHighLatency
expr: histogram_quantile(0.99, rate(fraud_detection_engine_latency_seconds_bucket[10m])) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Fraud Detection Engine high latency"
description: "p99 latency for the Fraud Detection Engine exceeds 100ms."

- alert: FraudDetectionSuspiciousAnomalySpike
expr: rate(fraud_detection_rule_hits_total[5m]) > 50
for: 3m
labels:
severity: warning
annotations:
summary: "Spike in fraud detection rule hits"
description: "Fraud rule {{ $labels.rule_name }} is triggering frequently for merchant {{ $labels.merchant_id }}."

- alert: FraudDetectionErrorRate
expr: rate(fraud_detection_errors_total[5m]) > 1
for: 2m
labels:
severity: critical
annotations:
summary: "Fraud Detection Engine error rate elevated"
description: "Fraud Detection Engine is experiencing errors."
89 changes: 87 additions & 2 deletions backend/src/lib/fraud-detection-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ import {
fraudDetectionGeographicAnomaly,
fraudDetectionMetadataAnomalies,
fraudDetectionCacheSize,
fraudDetectionAlertsFired,
fraudDetectionHealthStatus,
fraudDetectionRuleHits,
fraudDetectionEngineLatency,
fraudDetectionCacheHealth,
fraudDetectionAnomalyScore,
} from "./metrics.js";
import { sanitizeAndValidateFraudPayload, validateMerchantId } from "./fraud-detection-sanitizer.js";

const RISK_THRESHOLDS = {
low: 20,
Expand Down Expand Up @@ -90,6 +97,11 @@ function getCacheKey(key) {
}

export function clearCache(merchantId) {
const validation = validateMerchantId(merchantId);
if (!validation.valid) {
logger.warn({ errors: validation.errors }, '[FraudDetection] Invalid merchantId for cache clear');
return;
}
const keysToDelete = [];
for (const key of riskScoreCache.keys()) {
if (key.startsWith(`${merchantId}:`)) {
Expand Down Expand Up @@ -283,8 +295,23 @@ function calculateBaseRiskScore(payment) {
return { score, factors };
}

export function analyzePayment(payment, options = {}) {
const { includeHistoricalData = false } = options;
export function analyzePayment(payment, merchantId) {
// Sanitize and validate payload before any processing (#1427)
const validation = sanitizeAndValidateFraudPayload(payment, merchantId);
if (!validation.valid) {
fraudDetectionPaymentsAnalyzed.inc();
logger.warn({ errors: validation.errors }, '[FraudDetection] Payload rejected due to validation errors');
return {
riskLevel: 'unknown',
riskScore: 0,
flags: ['validation_failed'],
errors: validation.errors,
cached: false,
};
}
// Use sanitized payment data from this point
payment = validation.payload;
merchantId = validation.merchantId;

fraudDetectionPaymentsAnalyzed.inc();

Expand All @@ -295,21 +322,53 @@ export function analyzePayment(payment, options = {}) {
return cached.analysis;
}

// Start latency timer after cache check (#1428)
const endTimer = fraudDetectionEngineLatency.startTimer({ merchant_id: merchantId });

const { score: baseScore, factors: baseFactors } = calculateBaseRiskScore(payment);

// Track individual rule hits (#1428)
for (const factor of baseFactors) {
if (factor.type === 'large_amount') {
fraudDetectionRuleHits.inc({ rule_name: 'large_amount', merchant_id: merchantId });
} else if (factor.type === 'stale_payment') {
fraudDetectionRuleHits.inc({ rule_name: 'stale_payment', merchant_id: merchantId });
} else if (factor.type === 'missing_recipient') {
fraudDetectionRuleHits.inc({ rule_name: 'missing_recipient', merchant_id: merchantId });
} else if (factor.type === 'invalid_recipient_format') {
fraudDetectionRuleHits.inc({ rule_name: 'invalid_recipient_format', merchant_id: merchantId });
}
}

const paymentHash = `${payment.merchant_id}:${payment.recipient}:${payment.asset}`;
const velocityAnomalies = checkVelocityAnomalies(paymentHash, Number(payment.amount));
const velocityRisk = velocityAnomalies.length > 0 ? 20 : 0;

if (velocityAnomalies.length > 0) {
fraudDetectionRuleHits.inc({ rule_name: 'velocity_anomaly', merchant_id: merchantId });
}

const geographicAnomalies = checkGeographicAnomalies(payment, []);
const geographicRisk = geographicAnomalies.length > 0 ? 15 : 0;

if (geographicAnomalies.length > 0) {
fraudDetectionRuleHits.inc({ rule_name: 'geographic_anomaly', merchant_id: merchantId });
}

const metadataAnomalies = checkMetadataAnomalies(payment);
const metadataRisk = metadataAnomalies.length > 0 ? 10 : 0;

if (metadataAnomalies.length > 0) {
fraudDetectionRuleHits.inc({ rule_name: 'metadata_anomaly', merchant_id: merchantId });
}

const memoAnomalies = checkMemoAnomalies(payment);
const memoRisk = memoAnomalies.length > 0 ? 8 : 0;

if (memoAnomalies.length > 0) {
fraudDetectionRuleHits.inc({ rule_name: 'suspicious_memo', merchant_id: merchantId });
}

const totalScore = Math.min(
baseScore + velocityRisk + geographicRisk + metadataRisk + memoRisk,
100,
Expand All @@ -332,10 +391,15 @@ export function analyzePayment(payment, options = {}) {

if (totalScore >= RISK_THRESHOLDS.high) {
fraudDetectionHighRiskDetected.inc({ level: riskLevel });
// Fire alert counter for high/critical risk payments (#1428)
fraudDetectionAlertsFired.inc({ merchant_id: merchantId, risk_level: riskLevel, alert_type: 'payment_risk' });
}

fraudDetectionRiskScore.observe(totalScore);

// Record anomaly score for distribution tracking (#1428)
fraudDetectionAnomalyScore.observe({ merchant_id: merchantId }, totalScore);

const allAnomalies = [
...baseFactors,
...velocityAnomalies,
Expand Down Expand Up @@ -382,6 +446,9 @@ export function analyzePayment(payment, options = {}) {
"Fraud detection analysis complete",
);

// End latency timer with risk level label (#1428)
endTimer({ risk_level: riskLevel });

return analysis;
}

Expand Down Expand Up @@ -415,3 +482,21 @@ export function resetMetrics() {
velocityTracker.clear();
fraudDetectionCacheSize.set(0);
}

/**
* Returns health status of the Fraud Detection Engine and updates health telemetry metrics (#1428).
*/
export function getFraudDetectionHealthStatus() {
const cacheSize = riskScoreCache.size;
const isHealthy = cacheSize <= MAX_RISK_CACHE_ENTRIES;
fraudDetectionHealthStatus.set({ component: 'cache' }, isHealthy ? 1 : 0);
fraudDetectionHealthStatus.set({ component: 'engine' }, 1);
fraudDetectionCacheHealth.set({ metric_type: 'size' }, cacheSize);
fraudDetectionCacheHealth.set({ metric_type: 'max_entries' }, MAX_RISK_CACHE_ENTRIES);
return {
status: isHealthy ? 'healthy' : 'degraded',
cacheSize,
maxCacheEntries: MAX_RISK_CACHE_ENTRIES,
timestamp: new Date().toISOString(),
};
}
63 changes: 63 additions & 0 deletions backend/src/lib/fraud-detection-engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getCacheStats,
resetMetrics,
clearCache,
getFraudDetectionHealthStatus,
} from "./fraud-detection-engine.js";

vi.mock("./logger.js", () => ({
Expand All @@ -27,6 +28,24 @@ vi.mock("./metrics.js", () => ({
fraudDetectionGeographicAnomaly: { inc: vi.fn() },
fraudDetectionMetadataAnomalies: { inc: vi.fn() },
fraudDetectionCacheSize: { set: vi.fn() },
fraudDetectionAlertsFired: { inc: vi.fn() },
fraudDetectionHealthStatus: { set: vi.fn() },
fraudDetectionRuleHits: { inc: vi.fn() },
fraudDetectionEngineLatency: { startTimer: vi.fn(() => vi.fn()) },
fraudDetectionCacheHealth: { set: vi.fn() },
fraudDetectionAnomalyScore: { observe: vi.fn() },
}));

vi.mock("./fraud-detection-sanitizer.js", () => ({
sanitizeAndValidateFraudPayload: vi.fn((payment, merchantId) => ({
valid: true,
payload: payment,
merchantId: merchantId || 'unknown',
})),
validateMerchantId: vi.fn((merchantId) => ({
valid: !!merchantId,
merchantId,
})),
}));

describe("Fraud Detection Engine", () => {
Expand Down Expand Up @@ -501,4 +520,48 @@ describe("Fraud Detection Engine", () => {
);
});
});

describe('health telemetry (#1428)', () => {
it('should export getFraudDetectionHealthStatus', async () => {
expect(getFraudDetectionHealthStatus).toBeDefined();
});

it('should return healthy status when cache is under limit', async () => {
const health = getFraudDetectionHealthStatus();
expect(health.status).toBe('healthy');
expect(health.cacheSize).toBeGreaterThanOrEqual(0);
expect(health.maxCacheEntries).toBeGreaterThan(0);
expect(health.timestamp).toBeDefined();
});

it('should track engine latency via Prometheus histogram', async () => {
const mockPayment = {
id: 'latency-test-1',
amount: '10.00',
recipient: 'GCLATENCYTEST123456789012345678901234567890123456789',
status: 'pending',
created_at: new Date().toISOString(),
memo: 'latency-test',
metadata: {},
};
const result = await analyzePayment(mockPayment, 'merchant-latency-test');
expect(result).toHaveProperty('riskLevel');
expect(result).toHaveProperty('riskScore');
});

it('should fire alert counter for high-risk payments', async () => {
const highRiskPayment = {
id: 'alert-test-1',
amount: '999999.00',
recipient: 'GCALERTTEST1234567890123456789012345678901234567890',
status: 'pending',
created_at: new Date(Date.now() - 400000).toISOString(),
memo: 'URGENT WIRE TRANSFER IMMEDIATE',
metadata: {},
};
const result = await analyzePayment(highRiskPayment, 'merchant-alert-test');
// Result should be processed; alert metrics tracked internally
expect(result).toHaveProperty('riskLevel');
});
});
});
Loading