/** * CDP Edge — Fraud Detection (Fase 4) * checkFraudGate, logFraudSignal, handlers das rotas /api/fraud/* */ import { sha256, tryParseJson } from '../utils.js'; import { Env, TrackPayload } from '../../types.js'; // ── Tipos ─────────────────────────────────────────────────────────────────────── export interface FraudResult { allowed: boolean; score: number; reasons: string[]; action: 'allowed' | 'flagged' | 'dropped'; } export const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i; // ── checkFraudGate — roda ANTES de qualquer processamento de evento ──────────── // Retorna { allowed, score, reasons, action } // Falhas no gate = fail-safe (deixa passar) export async function checkFraudGate(env: Env, request: Request, payload: TrackPayload): Promise { const result: FraudResult = { allowed: true, score: 0, reasons: [], action: 'allowed' }; try { const ip = request.headers.get('CF-Connecting-IP') || ''; const ua = request.headers.get('User-Agent') || ''; const fingerprint = (payload as any).fingerprint || ''; const email = payload.email || ''; const botScore = parseInt(String(payload.botScore || (payload as any).bot_score || 0)); const asn = String((request as any).cf?.asOrganization || '').toLowerCase(); const country = ((request as any).cf?.country || '').toUpperCase(); const acceptLang = request.headers.get('Accept-Language'); // 1. KV blocklist check — instantâneo (~0ms) if (env.GEO_CACHE && ip) { const ipBlocked = await env.GEO_CACHE.get(`fraud_block:ip:${ip}`); if (ipBlocked) { return { allowed: false, score: 100, reasons: ['ip_blocklisted'], action: 'dropped' }; } } if (env.GEO_CACHE && fingerprint) { const fpBlocked = await env.GEO_CACHE.get(`fraud_block:fp:${fingerprint}`); if (fpBlocked) { return { allowed: false, score: 100, reasons: ['fingerprint_blocklisted'], action: 'dropped' }; } } // 2. Bot score if (botScore >= 3) { result.score += 60; result.reasons.push('bot_score_high'); } else if (botScore === 2) { result.score += 30; result.reasons.push('bot_score_medium'); } // 3. User-Agent suspeito if (/headless|phantomjs|selenium|webdriver|curl|python|scrapy|bot|crawler|spider/i.test(ua)) { result.score += 40; result.reasons.push('suspicious_user_agent'); } // 4. Datacenter IP — kill-switch opcional (FRAUD_BLOCK_DATACENTERS=1 dropa direto) if (ip && DATACENTER_PATTERNS.test(asn)) { if (env.FRAUD_BLOCK_DATACENTERS === '1') { return { allowed: false, score: 100, reasons: ['datacenter_ip_killswitch'], action: 'dropped' }; } result.score += 35; result.reasons.push('datacenter_ip'); } // 5. Sem Accept-Language if (!acceptLang) { result.score += 20; result.reasons.push('no_accept_language'); } // 5b. Geo-fence — country fora de ALLOWED_COUNTRIES (vazio = sem geo-fence) const allowed = (env.ALLOWED_COUNTRIES || '').toUpperCase().split(',').map(s => s.trim()).filter(Boolean); if (allowed.length > 0 && country && !allowed.includes(country)) { const penalty = parseInt(env.FRAUD_GEO_PENALTY || '50'); result.score += penalty; result.reasons.push(`country_blocked:${country}`); } // 6. Velocity check via KV if (env.GEO_CACHE && ip) { const velKey1h = `fraud_velocity:${ip}:h`; const velStr = await env.GEO_CACHE.get(velKey1h); const vel1h = parseInt(velStr || '0') + 1; await env.GEO_CACHE.put(velKey1h, String(vel1h), { expirationTtl: 3600 }); if (vel1h > 20) { result.score += 50; result.reasons.push('ip_velocity_very_high'); } else if (vel1h > 10) { result.score += 25; result.reasons.push('ip_velocity_high'); } } result.score = Math.min(100, result.score); // 8. Decisão final — threshold configurável via FRAUD_DROP_THRESHOLD (default 50) const dropThreshold = parseInt(env.FRAUD_DROP_THRESHOLD || '50'); if (result.score >= dropThreshold) { result.allowed = false; result.action = 'dropped'; } else if (result.score >= 40) { result.action = 'flagged'; } return result; } catch (err: any) { console.error('[Fraud] checkFraudGate error:', err?.message || String(err)); return { allowed: true, score: 0, reasons: ['gate_error_fallback'], action: 'allowed' }; } } // ── logFraudSignal — persiste no D1 em background ──────────────────────────── export async function logFraudSignal(env: Env, request: Request, payload: TrackPayload, fraudResult: FraudResult): Promise { if (!env.DB || fraudResult.action === 'allowed') return; try { const ip = request.headers.get('CF-Connecting-IP') || ''; const ua = request.headers.get('User-Agent') || ''; const fingerprint = (payload as any).fingerprint || ''; const botScore = parseInt(String(payload.botScore || (payload as any).bot_score || 0)); const asn = String((request as any).cf?.asOrganization || ''); const country = (request as any).cf?.country || ''; const velKey1h = `fraud_velocity:${ip}:h`; const vel1h = env.GEO_CACHE ? parseInt(await env.GEO_CACHE.get(velKey1h) || '0') : 0; let emailHash = null; if (payload.email) { try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch (err: any) { console.error('[Fraud] Error generating email hash:', { email: payload.email, error: err?.message || String(err), stack: err?.stack, }); } } await env.DB.prepare(` INSERT INTO fraud_signals ( ip_address, fingerprint, user_id, email_hash, event_name, event_id, fraud_score, action_taken, reasons, ip_country, ip_asn, user_agent, bot_score, velocity_1h, detected_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) `).bind( ip, fingerprint || null, payload.userId || null, emailHash, payload.eventName || null, payload.eventId || null, fraudResult.score, fraudResult.action, JSON.stringify(fraudResult.reasons), country, asn, ua.substring(0, 255), botScore, vel1h, ).run(); if (fraudResult.action === 'dropped' && ip) { await env.DB.prepare(` INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, top_reasons) VALUES ('ip_attack', 'ip', ?, 1, 1, ?, datetime('now'), datetime('now'), ?) ON CONFLICT(entity_type, entity_value) DO UPDATE SET events_total = events_total + 1, events_dropped = events_dropped + 1, peak_score = MAX(peak_score, excluded.peak_score), last_seen = datetime('now'), updated_at = datetime('now') `).bind(ip, fraudResult.score, JSON.stringify(fraudResult.reasons)).run().catch(() => {}); } } catch (err: any) { console.error('[Fraud] logFraudSignal error:', err?.message || String(err)); } } // ── GET /api/fraud/alerts ───────────────────────────────────────────────────── export async function handleFraudAlerts(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); const url = new URL(request.url); const action = url.searchParams.get('action') || null; const hours = parseInt(url.searchParams.get('hours') || '24'); const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200); try { const cond = action ? 'AND action_taken = ?' : ''; const bindings = action ? [hours, action, limit] : [hours, limit]; const result = await env.DB.prepare(` SELECT ip_address, fingerprint, event_name, fraud_score, action_taken, reasons, ip_country, ip_asn, bot_score, velocity_1h, detected_at FROM fraud_signals WHERE detected_at >= datetime('now', '-' || ? || ' hours') ${cond} ORDER BY fraud_score DESC, detected_at DESC LIMIT ? `).bind(...bindings).all(); const signals = (result.results || []).map((s: any) => ({ ...s, reasons: tryParseJson(s.reasons, []) })); const stats = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first().catch(() => null); return new Response(JSON.stringify({ success: true, period_hours: hours, total: signals.length, stats, alerts: signals }), { status: 200, headers }); } catch (err: any) { console.error('[Fraud] alerts error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── GET /api/fraud/blocklist ────────────────────────────────────────────────── export async function handleFraudBlocklist(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); try { const result = await env.DB.prepare(` SELECT entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, blocked_at, block_expires, top_reasons FROM fraud_alerts WHERE is_blocked = 1 ORDER BY events_dropped DESC LIMIT 100 `).all(); const blocklist = (result.results || []).map((r: any) => ({ ...r, top_reasons: tryParseJson(r.top_reasons, []) })); return new Response(JSON.stringify({ success: true, total: blocklist.length, blocklist }), { status: 200, headers }); } catch (err: any) { console.error('[Fraud] blocklist error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── POST /api/fraud/blocklist/add ───────────────────────────────────────────── export async function handleFraudBlocklistAdd(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); let body: any; try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); } const { entity_type, entity_value, ttl_hours = 24, reason = 'manual_block' } = body; if (!entity_type || !entity_value) { return new Response(JSON.stringify({ error: 'entity_type (ip|fingerprint) e entity_value são obrigatórios' }), { status: 400, headers }); } if (!['ip', 'fingerprint'].includes(entity_type)) { return new Response(JSON.stringify({ error: 'entity_type deve ser: ip ou fingerprint' }), { status: 400, headers }); } try { const kvKey = `fraud_block:${entity_type}:${entity_value}`; const ttlSec = Math.min(ttl_hours * 3600, 7 * 24 * 3600); const expiresAt = new Date(Date.now() + ttlSec * 1000).toISOString(); if (env.GEO_CACHE) { await env.GEO_CACHE.put(kvKey, JSON.stringify({ reason, blocked_at: new Date().toISOString() }), { expirationTtl: ttlSec }); } await env.DB.prepare(` INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, is_blocked, blocked_at, block_expires, top_reasons) VALUES ('manual', ?, ?, 0, 0, 100, datetime('now'), datetime('now'), 1, datetime('now'), ?, ?) ON CONFLICT DO UPDATE SET is_blocked = 1, blocked_at = datetime('now'), block_expires = excluded.block_expires, updated_at = datetime('now') `).bind(entity_type, entity_value, expiresAt, JSON.stringify([reason])).run().catch(() => {}); return new Response(JSON.stringify({ success: true, entity_type, entity_value, kv_key: kvKey, ttl_hours, expires_at: expiresAt, message: `${entity_type} '${entity_value}' bloqueado por ${ttl_hours}h. Efeito imediato via KV.`, }), { status: 200, headers }); } catch (err: any) { console.error('[Fraud] blocklist add error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── DELETE /api/fraud/blocklist/remove ─────────────────────────────────────── export async function handleFraudBlocklistRemove(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); let body: any; try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); } const { entity_type, entity_value } = body; if (!entity_type || !entity_value) { return new Response(JSON.stringify({ error: 'entity_type e entity_value são obrigatórios' }), { status: 400, headers }); } try { const kvKey = `fraud_block:${entity_type}:${entity_value}`; if (env.GEO_CACHE) await env.GEO_CACHE.delete(kvKey); await env.DB.prepare(`UPDATE fraud_alerts SET is_blocked = 0, resolved_at = datetime('now'), resolved_by = 'manual' WHERE entity_type = ? AND entity_value = ?`).bind(entity_type, entity_value).run(); return new Response(JSON.stringify({ success: true, entity_type, entity_value, message: `${entity_type} '${entity_value}' removido do blocklist. Efeito imediato via KV.`, }), { status: 200, headers }); } catch (err: any) { console.error('[Fraud] blocklist remove error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── GET /api/fraud/stats ────────────────────────────────────────────────────── export async function handleFraudStats(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); try { const dashboard = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first(); const topIps = await env.DB.prepare(` SELECT ip_address, COUNT(*) as events, MAX(fraud_score) as peak_score FROM fraud_signals WHERE detected_at >= datetime('now', '-24 hours') AND action_taken = 'dropped' GROUP BY ip_address ORDER BY events DESC LIMIT 10 `).all(); const topReasons = await env.DB.prepare(` SELECT action_taken, COUNT(*) as count FROM fraud_signals WHERE detected_at >= datetime('now', '-24 hours') GROUP BY action_taken `).all(); return new Response(JSON.stringify({ success: true, period: '24h', dashboard, top_attacking_ips: topIps.results || [], by_action: topReasons.results || [], }), { status: 200, headers }); } catch (err: any) { console.error('[Fraud] stats error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } }