Files
fluxer/fluxer_api/src/api/risk/DeterministicRiskEngine.ts
T

862 lines
29 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {AbuseProneEmailTldRisk, classifyAbuseProneEmailTld} from './AbuseProneEmailTlds';
import {isMajorEmailProvider} from './MajorEmailProviders';
import {derivePlusAddressBase} from './PlusAddressUtils';
import type {RiskToolbox} from './RiskToolbox';
import {
type HistoricalOutcomeResult,
type IpInfoAnonymousResult,
RecommendedAction,
type RegistrationEvent,
type RiskAssessment,
RiskConfidence,
RiskDecisionMethod,
RiskLevel,
type RiskSignals,
} from './RiskTypes';
import {isTrustedCommercialPrivacyProvider} from './TrustedPrivacyProviders';
interface DeterministicRiskEngineLogger {
info(payload: object, msg: string): void;
warn(payload: object, msg: string): void;
error(payload: object, msg: string): void;
}
interface DeterministicRiskEngineConfig {
logger: DeterministicRiskEngineLogger;
}
const NULL_LOGGER: DeterministicRiskEngineLogger = {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const DEFAULT_CONFIG: DeterministicRiskEngineConfig = {
logger: NULL_LOGGER,
};
const ENGINE_NAME = 'deterministic';
interface ScoreContribution {
readonly rule: string;
readonly points: number;
readonly reason: string;
readonly flags?: {
readonly floorsToHigh?: boolean;
};
}
interface VelocityTier {
readonly minCount: number;
readonly points: number;
readonly severity: string;
}
const THRESHOLD_MEDIUM = 30;
const THRESHOLD_HIGH = 60;
const THRESHOLD_VERY_HIGH = 80;
const RESI_PROXY_PERSISTENCE_HIGH = 70;
const RESI_PROXY_PERSISTENCE_MEDIUM = 30;
const POINTS_RESI_PROXY_HIGH_PERSISTENCE = 45;
const POINTS_RESI_PROXY_MEDIUM_PERSISTENCE = 20;
const POINTS_RESI_PROXY_LOW_PERSISTENCE = 10;
const POINTS_UNTRUSTED_VPN = 25;
const POINTS_RESI_PROXY_HOSTING_CROSSTAG = POINTS_UNTRUSTED_VPN;
const MOBILE_PROXY_DAMPEN_MULTIPLIER = 0.6;
const FREQUENT_ABUSER_WINDOW_HOURS = 180 * 24;
const FREQUENT_ABUSER_MIN_ENFORCED = 5;
const POINTS_FREQUENT_ABUSER_IP = 40;
const POINTS_FREQUENT_ABUSER_SUBNET = 25;
const POINTS_SUSPICIOUS_IP = 35;
const SHARED_CONN_MIN_REGISTRATIONS = 3;
const SHARED_CONN_CAP_POINTS = 8;
const IP_VELOCITY_TIERS: ReadonlyArray<VelocityTier> = [
{minCount: 5, points: 40, severity: 'high'},
{minCount: 3, points: 20, severity: 'moderate'},
{minCount: 1, points: 8, severity: 'low'},
];
const SUBNET_VELOCITY_TIERS: ReadonlyArray<VelocityTier> = [
{minCount: 15, points: 25, severity: 'high'},
{minCount: 8, points: 12, severity: 'moderate'},
];
const OUTCOME_WEIGHT_IP = 1.0;
const OUTCOME_WEIGHT_SUBNET = 0.7;
const OUTCOME_MIN_RATIO = 0.05;
const OUTCOME_MIN_ABSOLUTE = 2;
const OUTCOME_STRONG_RATIO = 0.3;
const OUTCOME_STRONG_ABSOLUTE = 3;
const OUTCOME_MODERATE_RATIO = 0.15;
const OUTCOME_MODERATE_ABSOLUTE = 2;
const OUTCOME_POINTS_STRONG = 35;
const OUTCOME_POINTS_MODERATE = 20;
const OUTCOME_POINTS_WEAK = 8;
const ASN_OUTCOME_MIN_ENFORCED = 3;
const ASN_OUTCOME_MIN_RATIO = 0.15;
const ASN_OUTCOME_RATIO_MULTIPLIER = 30;
const ASN_OUTCOME_POINTS_MAX = 20;
const EMAIL_GIBBERISH_THRESHOLD = 70;
const EMAIL_SUSPICIOUS_THRESHOLD = 50;
const POINTS_EMAIL_GIBBERISH = 20;
const POINTS_EMAIL_SUSPICIOUS = 10;
const POINTS_DISPOSABLE_DOMAIN = 25;
const PLUS_ADDRESS_TIERS: ReadonlyArray<VelocityTier> = [
{minCount: 4, points: 65, severity: 'heavy'},
{minCount: 3, points: 30, severity: 'elevated'},
];
const POINTS_FRESH_DOMAIN = 15;
const POINTS_FRESH_DOMAIN_WITH_ABUSE_TLD = 20;
const POINTS_ABUSE_PRONE_TLD = 10;
const POINTS_NO_MX = 15;
const DOMAIN_VELOCITY_TIERS: ReadonlyArray<VelocityTier> = [
{minCount: 5, points: 20, severity: 'high'},
{minCount: 3, points: 10, severity: 'moderate'},
];
const POINTS_UA_BOT = 40;
const POINTS_UA_HEADLESS = 30;
const POINTS_UA_SUSPICIOUS = 10;
const POINTS_GEO_MISMATCH = 5;
const POINTS_SUSPICIOUS_HOUR = 3;
const CONFIDENCE_MIN_CONTRIBUTIONS_HIGH = 3;
const CONFIDENCE_MIN_CONTRIBUTIONS_MEDIUM = 2;
const CONFIDENCE_LOW_SCORE_MARGIN = 10;
const RULE = {
ipReputation: 'ip_reputation',
ipVelocity: 'ip_velocity',
subnetVelocity: 'subnet_velocity',
historicalOutcomeIp: 'historical_outcome_ip',
historicalOutcomeSubnet: 'historical_outcome_subnet',
historicalOutcomeAsn: 'historical_outcome_asn',
emailGibberish: 'email_gibberish',
emailDisposable: 'email_disposable',
plusAddressReuse: 'plus_address_reuse',
domainAge: 'domain_age',
domainTld: 'domain_tld',
domainMx: 'domain_mx',
domainVelocity: 'domain_velocity',
userAgent: 'user_agent',
geoMismatch: 'geo_locale_mismatch',
suspiciousHour: 'suspicious_hour',
policyResidentialProxyFloor: 'policy_residential_proxy_floor',
frequentAbuserIp: 'frequent_abuser_ip',
frequentAbuserSubnet: 'frequent_abuser_subnet',
suspiciousIp: 'suspicious_ip',
sharedConnectionDampener: 'shared_connection_dampener',
} as const;
function extractEmailDomain(email: string | null | undefined): string | null {
if (!email) return null;
const at = email.lastIndexOf('@');
if (at < 0 || at === email.length - 1) return null;
return email.slice(at + 1).toLowerCase();
}
function isNicheEmailDomain(domain: string | null): domain is string {
return domain !== null && !isMajorEmailProvider(domain);
}
function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(Math.max(value, min), max);
}
async function settled<T>(
promise: Promise<T | null> | null,
logger: DeterministicRiskEngineLogger,
source: string,
): Promise<T | undefined> {
if (promise === null) return undefined;
try {
const value = await promise;
return value ?? undefined;
} catch (err) {
logger.warn(
{source, error: err instanceof Error ? err.message : String(err)},
'DeterministicRiskEngine: signal lookup failed',
);
return undefined;
}
}
function scoreByTiers(
count: number,
tiers: ReadonlyArray<VelocityTier>,
rule: string,
formatReason: (count: number, severity: string) => string,
): ScoreContribution | null {
for (const tier of tiers) {
if (count >= tier.minCount) {
return {
rule,
points: tier.points,
reason: formatReason(count, tier.severity),
};
}
}
return null;
}
function scoreIpReputation(signals: RiskSignals): Array<ScoreContribution> {
const ipInfo = signals.ipInfoAnonymous;
if (!ipInfo?.available) return [];
const providerName = ipInfo.providerName ?? null;
const asnType = ipInfo.asnType?.toLowerCase() ?? null;
const isHostingAsn = ipInfo.isHosting || asnType === 'hosting';
const isTrustedVpn = isTrustedCommercialPrivacyProvider(providerName);
const isMobileService = /_mobile$/i.test(providerName ?? '') || asnType === 'mobile';
if (isTrustedVpn) {
return [
{
rule: RULE.ipReputation,
points: 0,
reason: `trusted commercial VPN (provider: ${providerName})`,
},
];
}
if (ipInfo.isResidentialProxy) {
if (isHostingAsn) {
return [
{
rule: RULE.ipReputation,
points: POINTS_RESI_PROXY_HOSTING_CROSSTAG,
reason: `residential-proxy cross-tag on hosting ASN — treating as untrusted commercial VPN (provider: ${providerName ?? 'unknown'})`,
flags: {floorsToHigh: true},
},
];
}
const persistence = ipInfo.percentDaysSeen;
const provLabel = providerName ?? 'unknown';
if (persistence == null) {
let points = POINTS_RESI_PROXY_MEDIUM_PERSISTENCE;
if (isMobileService) points = Math.round(points * MOBILE_PROXY_DAMPEN_MULTIPLIER);
return [
{
rule: RULE.ipReputation,
points,
reason: `residential proxy (provider: ${provLabel}, persistence: unknown${isMobileService ? ', mobile gateway' : ''})`,
flags: {floorsToHigh: true},
},
];
}
if (persistence >= RESI_PROXY_PERSISTENCE_HIGH) {
let points = POINTS_RESI_PROXY_HIGH_PERSISTENCE;
if (isMobileService) points = Math.round(points * MOBILE_PROXY_DAMPEN_MULTIPLIER);
return [
{
rule: RULE.ipReputation,
points,
reason: `persistent residential proxy (provider: ${provLabel}, persistence: ${persistence}%${isMobileService ? ', mobile gateway' : ''})`,
flags: {floorsToHigh: true},
},
];
}
if (persistence >= RESI_PROXY_PERSISTENCE_MEDIUM) {
let points = POINTS_RESI_PROXY_MEDIUM_PERSISTENCE;
if (isMobileService) points = Math.round(points * MOBILE_PROXY_DAMPEN_MULTIPLIER);
return [
{
rule: RULE.ipReputation,
points,
reason: `intermittent residential proxy (provider: ${provLabel}, persistence: ${persistence}%${isMobileService ? ', mobile gateway' : ''})`,
flags: {floorsToHigh: true},
},
];
}
let points = POINTS_RESI_PROXY_LOW_PERSISTENCE;
if (isMobileService) points = Math.round(points * MOBILE_PROXY_DAMPEN_MULTIPLIER);
return [
{
rule: RULE.ipReputation,
points,
reason: `transient residential proxy sighting (provider: ${provLabel}, persistence: ${persistence}%${isMobileService ? ', mobile gateway' : ''})`,
flags: {floorsToHigh: true},
},
];
}
const isAnonymizingNetwork = ipInfo.isVpn || ipInfo.isProxy || isHostingAsn || ipInfo.isHosting;
if (isAnonymizingNetwork) {
const label = ipInfo.isVpn || ipInfo.isProxy ? 'untrusted VPN/proxy' : 'hosting/datacenter network';
return [
{
rule: RULE.ipReputation,
points: POINTS_UNTRUSTED_VPN,
reason: `${label} detected by IPinfo (provider: ${providerName ?? 'unknown'}, connection_type: ${ipInfo.connectionType})`,
},
];
}
return [];
}
function isTrustedCommercialPrivacySignal(ipInfo: IpInfoAnonymousResult | undefined): boolean {
if (!ipInfo?.available) return false;
if (!ipInfo.isAnonymous && !ipInfo.isVpn && !ipInfo.isProxy && !ipInfo.isRelay) return false;
return isTrustedCommercialPrivacyProvider(ipInfo.providerName);
}
function isHighSharedAccessSignal(ipInfo: IpInfoAnonymousResult | undefined): boolean {
if (!ipInfo?.available || ipInfo.isAnonymous || ipInfo.isHosting) return false;
const asnType = ipInfo.asnType?.trim().toLowerCase() ?? null;
return ipInfo.isMobile || ipInfo.connectionType === 'mobile' || asnType === 'mobile';
}
function scoreSuspiciousIp(signals: RiskSignals): Array<ScoreContribution> {
const suspiciousIp = signals.suspiciousIp;
if (!suspiciousIp) return [];
const ipInfo = signals.ipInfoAnonymous;
if (isHighSharedAccessSignal(ipInfo)) {
return [
{
rule: RULE.suspiciousIp,
points: 0,
reason: `suspicious IP marker ignored for shared mobile/CGNAT network: ${suspiciousIp.ip}`,
},
];
}
return [
{
rule: RULE.suspiciousIp,
points: POINTS_SUSPICIOUS_IP,
reason: `suspicious IP marker from ${suspiciousIp.source}: ${suspiciousIp.reason}`,
},
];
}
function scoreIpVelocity(signals: RiskSignals): Array<ScoreContribution> {
const contributions: Array<ScoreContribution> = [];
const byIp = signals.registrationsByIp;
if (byIp && byIp.totalRegistrations > 0) {
const c = scoreByTiers(
byIp.totalRegistrations,
IP_VELOCITY_TIERS,
RULE.ipVelocity,
(n, sev) => `${sev} IP velocity: ${n} registrations in ${byIp.windowHours}h`,
);
if (c) contributions.push(c);
}
const bySub = signals.registrationsBySubnet;
if (bySub) {
const c = scoreByTiers(
bySub.totalRegistrations,
SUBNET_VELOCITY_TIERS,
RULE.subnetVelocity,
(n, sev) => `${sev} subnet velocity: ${n} registrations in ${bySub.windowHours}h`,
);
if (c) contributions.push(c);
}
return contributions;
}
function scoreHistoricalOutcomes(signals: RiskSignals): Array<ScoreContribution> {
const contributions: Array<ScoreContribution> = [];
const perSource: Array<ScoreContribution | null> = [
scoreOutcomeRatio(signals.historicalOutcomesByIp, RULE.historicalOutcomeIp, 'IP', OUTCOME_WEIGHT_IP),
scoreOutcomeRatio(
signals.historicalOutcomesBySubnet,
RULE.historicalOutcomeSubnet,
'subnet',
OUTCOME_WEIGHT_SUBNET,
),
];
for (const c of perSource) {
if (c) contributions.push(c);
}
const asn = signals.historicalOutcomesByAsn;
if (asn && asn.enforcedUsers >= ASN_OUTCOME_MIN_ENFORCED && asn.sampledUsers > 0) {
const ratio = asn.enforcedUsers / asn.sampledUsers;
if (ratio > ASN_OUTCOME_MIN_RATIO) {
const points = Math.min(Math.round(ratio * ASN_OUTCOME_RATIO_MULTIPLIER), ASN_OUTCOME_POINTS_MAX);
contributions.push({
rule: RULE.historicalOutcomeAsn,
points,
reason: `ASN enforcement ratio: ${asn.enforcedUsers}/${asn.sampledUsers} enforced (${(ratio * 100).toFixed(0)}%)`,
});
}
}
return contributions;
}
function scoreOutcomeRatio(
outcome: HistoricalOutcomeResult | undefined,
rule: string,
label: string,
weight: number,
): ScoreContribution | null {
if (!outcome || outcome.sampledUsers === 0) return null;
const enforced = outcome.enforcedUsers + outcome.disabledUsers + outcome.spammerUsers;
if (enforced === 0) return null;
const ratio = enforced / outcome.sampledUsers;
if (ratio < OUTCOME_MIN_RATIO && enforced < OUTCOME_MIN_ABSOLUTE) return null;
const pct = (ratio * 100).toFixed(0);
let rawPoints: number;
let reason: string;
if (enforced >= OUTCOME_STRONG_ABSOLUTE && ratio > OUTCOME_STRONG_RATIO) {
rawPoints = OUTCOME_POINTS_STRONG;
reason = `high ${label} enforcement: ${enforced}/${outcome.sampledUsers} (${pct}%)`;
} else if (enforced >= OUTCOME_MODERATE_ABSOLUTE || ratio > OUTCOME_MODERATE_RATIO) {
rawPoints = OUTCOME_POINTS_MODERATE;
reason = `moderate ${label} enforcement: ${enforced}/${outcome.sampledUsers} (${pct}%)`;
} else {
rawPoints = OUTCOME_POINTS_WEAK;
reason = `some ${label} enforcement: ${enforced}/${outcome.sampledUsers}`;
}
return {rule, points: Math.round(rawPoints * weight), reason};
}
function scoreEmail(event: RegistrationEvent, signals: RiskSignals): Array<ScoreContribution> {
if (!event.email) return [];
const contributions: Array<ScoreContribution> = [];
const syntax = signals.emailSyntax;
if (syntax) {
if (syntax.gibberishScore >= EMAIL_GIBBERISH_THRESHOLD) {
contributions.push({
rule: RULE.emailGibberish,
points: POINTS_EMAIL_GIBBERISH,
reason: `gibberish email local-part (score: ${syntax.gibberishScore})`,
});
} else if (syntax.gibberishScore >= EMAIL_SUSPICIOUS_THRESHOLD) {
contributions.push({
rule: RULE.emailGibberish,
points: POINTS_EMAIL_SUSPICIOUS,
reason: `suspicious email local-part (score: ${syntax.gibberishScore})`,
});
}
}
if (signals.domainDisposable?.isDisposable) {
contributions.push({
rule: RULE.emailDisposable,
points: POINTS_DISPOSABLE_DOMAIN,
reason: `disposable email domain: ${signals.domainDisposable.domain}`,
});
}
const plus = signals.registrationsByPlusAddressBase;
if (plus) {
const c = scoreByTiers(
plus.uniqueEmails,
PLUS_ADDRESS_TIERS,
RULE.plusAddressReuse,
(n, sev) => `${sev} plus-address reuse: ${n} distinct aliases for ${plus.identifier} in ${plus.windowHours}h`,
);
if (c) contributions.push(c);
}
return contributions;
}
function scoreEmailDomain(signals: RiskSignals, domain: string): Array<ScoreContribution> {
const contributions: Array<ScoreContribution> = [];
const tld = domain.split('.').at(-1) ?? '';
const tldRisk = classifyAbuseProneEmailTld(tld);
if (signals.domainAge?.isNewlyRegistered) {
contributions.push({
rule: RULE.domainAge,
points: POINTS_FRESH_DOMAIN,
reason: `newly registered email domain (${signals.domainAge.ageDays} days old)`,
});
if (tldRisk === AbuseProneEmailTldRisk.High) {
contributions.push({
rule: RULE.domainTld,
points: POINTS_FRESH_DOMAIN_WITH_ABUSE_TLD,
reason: `abuse-prone TLD (.${tld}) with fresh domain`,
});
}
} else if (tldRisk === AbuseProneEmailTldRisk.High) {
contributions.push({
rule: RULE.domainTld,
points: POINTS_ABUSE_PRONE_TLD,
reason: `non-allowlisted TLD (.${tld})`,
});
}
if (signals.domainMx && !signals.domainMx.hasMx) {
contributions.push({
rule: RULE.domainMx,
points: POINTS_NO_MX,
reason: `no MX records for domain ${domain}`,
});
}
const vel = signals.registrationsByEmailDomain;
if (vel) {
const c = scoreByTiers(
vel.totalRegistrations,
DOMAIN_VELOCITY_TIERS,
RULE.domainVelocity,
(n) => `email domain velocity: ${n} registrations in ${vel.windowHours}h`,
);
if (c) contributions.push(c);
}
return contributions;
}
function scoreUserAgent(signals: RiskSignals): Array<ScoreContribution> {
const ua = signals.userAgent;
if (!ua) return [];
if (ua.isBot || ua.isAutomation) {
return [
{
rule: RULE.userAgent,
points: POINTS_UA_BOT,
reason: `bot/automation UA detected: ${ua.riskNote}`,
},
];
}
if (ua.isHeadless) {
return [
{
rule: RULE.userAgent,
points: POINTS_UA_HEADLESS,
reason: `headless browser UA: ${ua.riskNote}`,
},
];
}
if (ua.suspiciousPatterns.length > 0) {
return [
{
rule: RULE.userAgent,
points: POINTS_UA_SUSPICIOUS,
reason: `suspicious UA patterns: ${ua.suspiciousPatterns.join(', ')}`,
},
];
}
return [];
}
function scoreGeoLocale(signals: RiskSignals): Array<ScoreContribution> {
const geo = signals.localeGeoMatch;
if (!geo || !geo.mismatchDetected) return [];
return [
{
rule: RULE.geoMismatch,
points: POINTS_GEO_MISMATCH,
reason: `geo-locale mismatch: ${geo.notes.join(', ')}`,
},
];
}
function scoreFrequentAbuser(signals: RiskSignals): Array<ScoreContribution> {
const contributions: Array<ScoreContribution> = [];
const byIp = signals.frequentAbuserByIp;
if (byIp) {
const enforced = byIp.enforcedUsers + byIp.disabledUsers + byIp.spammerUsers;
if (enforced >= FREQUENT_ABUSER_MIN_ENFORCED) {
contributions.push({
rule: RULE.frequentAbuserIp,
points: POINTS_FREQUENT_ABUSER_IP,
reason: `frequent abuser IP: ${enforced} enforcements in the last ${byIp.windowHours / 24}d`,
});
}
}
const bySubnet = signals.frequentAbuserBySubnet;
if (bySubnet) {
const enforced = bySubnet.enforcedUsers + bySubnet.disabledUsers + bySubnet.spammerUsers;
if (enforced >= FREQUENT_ABUSER_MIN_ENFORCED) {
contributions.push({
rule: RULE.frequentAbuserSubnet,
points: POINTS_FREQUENT_ABUSER_SUBNET,
reason: `frequent abuser subnet: ${enforced} enforcements in the last ${bySubnet.windowHours / 24}d`,
});
}
}
return contributions;
}
function applySharedConnectionDampener(
contributions: Array<ScoreContribution>,
signals: RiskSignals,
): Array<ScoreContribution> {
const byIp = signals.registrationsByIp;
if (!byIp || byIp.totalRegistrations < SHARED_CONN_MIN_REGISTRATIONS) {
return contributions;
}
const shortOutcome = signals.historicalOutcomesByIp;
const longOutcome = signals.frequentAbuserByIp;
const hasShortEnforcement =
shortOutcome != null && shortOutcome.enforcedUsers + shortOutcome.disabledUsers + shortOutcome.spammerUsers > 0;
const hasLongEnforcement =
longOutcome != null && longOutcome.enforcedUsers + longOutcome.disabledUsers + longOutcome.spammerUsers > 0;
if (hasShortEnforcement || hasLongEnforcement) return contributions;
const ipInfo = signals.ipInfoAnonymous;
const isCommercialAnonymizer = ipInfo?.isVpn || ipInfo?.isProxy || ipInfo?.isHosting;
if (isCommercialAnonymizer) return contributions;
const connectionType = ipInfo?.connectionType ?? null;
const isSharedCandidate =
connectionType === 'residential' ||
connectionType === 'mobile' ||
connectionType === 'education' ||
(connectionType == null && signals.reverseDns?.classification === 'dynamic');
if (!isSharedCandidate) return contributions;
const rdnsClass = signals.reverseDns?.classification;
if (rdnsClass === 'static' || rdnsClass === 'business') {
return contributions;
}
const ipVelocityContrib = contributions.find((c) => c.rule === RULE.ipVelocity);
if (!ipVelocityContrib || ipVelocityContrib.points <= SHARED_CONN_CAP_POINTS) {
return contributions;
}
const delta = ipVelocityContrib.points - SHARED_CONN_CAP_POINTS;
const next = contributions.filter((c) => c !== ipVelocityContrib);
next.push({
rule: RULE.ipVelocity,
points: SHARED_CONN_CAP_POINTS,
reason: `${ipVelocityContrib.reason} (capped — shared ${connectionType ?? 'residential'} connection)`,
});
next.push({
rule: RULE.sharedConnectionDampener,
points: -delta,
reason: `shared ${connectionType ?? 'residential'} connection detected — IP velocity dampened by ${delta} points`,
});
return next;
}
function scoreTiming(signals: RiskSignals): Array<ScoreContribution> {
const timing = signals.registrationTiming;
if (!timing || !timing.isSuspiciousHour) return [];
return [
{
rule: RULE.suspiciousHour,
points: POINTS_SUSPICIOUS_HOUR,
reason: `registration at suspicious local hour (${timing.localHour})`,
},
];
}
function collectContributions(event: RegistrationEvent, signals: RiskSignals): Array<ScoreContribution> {
let contributions: Array<ScoreContribution> = [];
const trustedCommercialPrivacy = isTrustedCommercialPrivacySignal(signals.ipInfoAnonymous);
contributions.push(...scoreIpReputation(signals));
if (!trustedCommercialPrivacy) {
contributions.push(...scoreSuspiciousIp(signals));
contributions.push(...scoreIpVelocity(signals));
contributions.push(...scoreHistoricalOutcomes(signals));
contributions.push(...scoreFrequentAbuser(signals));
}
if (!event.isUnclaimed) {
contributions.push(...scoreEmail(event, signals));
const emailDomain = extractEmailDomain(event.email);
if (isNicheEmailDomain(emailDomain)) {
contributions.push(...scoreEmailDomain(signals, emailDomain));
}
}
contributions.push(...scoreUserAgent(signals));
if (!trustedCommercialPrivacy) {
contributions.push(...scoreGeoLocale(signals));
}
contributions.push(...scoreTiming(signals));
if (!trustedCommercialPrivacy) {
contributions = applySharedConnectionDampener(contributions, signals);
}
return contributions;
}
function scoreToLevel(score: number): RiskLevel {
if (score >= THRESHOLD_VERY_HIGH) return RiskLevel.VeryHigh;
if (score >= THRESHOLD_HIGH) return RiskLevel.High;
if (score >= THRESHOLD_MEDIUM) return RiskLevel.Medium;
return RiskLevel.Low;
}
function levelToAction(level: RiskLevel): RecommendedAction {
switch (level) {
case RiskLevel.VeryHigh:
return RecommendedAction.RequireInboundPhone;
case RiskLevel.High:
return RecommendedAction.RequireOutboundPhone;
case RiskLevel.Medium:
return RecommendedAction.RequireVerifiedEmail;
case RiskLevel.Low:
return RecommendedAction.Allow;
}
}
function deriveConfidence(score: number, contributionCount: number): RiskConfidence {
if (contributionCount === 0) {
return RiskConfidence.High;
}
if (
contributionCount >= CONFIDENCE_MIN_CONTRIBUTIONS_HIGH &&
(score >= THRESHOLD_HIGH || score < THRESHOLD_MEDIUM - CONFIDENCE_LOW_SCORE_MARGIN)
) {
return RiskConfidence.High;
}
if (contributionCount >= CONFIDENCE_MIN_CONTRIBUTIONS_MEDIUM) return RiskConfidence.Medium;
return RiskConfidence.Low;
}
export class DeterministicRiskEngine {
private readonly toolbox: RiskToolbox;
private readonly config: DeterministicRiskEngineConfig;
constructor(toolbox: RiskToolbox, configOverrides: Partial<DeterministicRiskEngineConfig> = {}) {
this.toolbox = toolbox;
this.config = {...DEFAULT_CONFIG, ...configOverrides};
}
async classify(event: RegistrationEvent): Promise<RiskAssessment> {
const startedAt = Date.now();
const signals = await this.collectSignals(event);
const contributions = collectContributions(event, signals);
let rawScore = 0;
for (const c of contributions) rawScore += c.points;
const score = clamp(Math.round(rawScore), 0, 100);
let level = scoreToLevel(score);
const floorTrigger = contributions.find((c) => c.flags?.floorsToHigh === true);
if (floorTrigger && (level === RiskLevel.Low || level === RiskLevel.Medium)) {
level = RiskLevel.High;
contributions.push({
rule: RULE.policyResidentialProxyFloor,
points: 0,
reason: `policy: ${floorTrigger.reason} — floor raised to High`,
});
}
const action = levelToAction(level);
const elapsedMs = Date.now() - startedAt;
const reasoning = contributions.map((c) => c.reason).join('; ');
this.config.logger.info(
{
ip: event.ip,
email: event.email,
score,
level,
elapsedMs,
contributionCount: contributions.length,
contributions: contributions.map((c) => ({rule: c.rule, points: c.points})),
},
'DeterministicRiskEngine: classification complete',
);
return {
suspicious: level !== RiskLevel.Low,
level,
confidence: deriveConfidence(score, contributions.length),
riskScore: score,
reasoning,
recommendedAction: action,
method: RiskDecisionMethod.Deterministic,
modelUsed: ENGINE_NAME,
rounds: 0,
elapsedMs,
signals,
};
}
private async collectSignals(event: RegistrationEvent): Promise<RiskSignals> {
const {logger} = this.config;
const toolbox = this.toolbox;
const ip = event.ip;
const emailDomain = extractEmailDomain(event.email);
const nicheDomain: string | null = isNicheEmailDomain(emailDomain) ? emailDomain : null;
const plusAddressBase = derivePlusAddressBase(event.email);
const geoIpAsnP = settled(toolbox.lookupGeoIpAsn({ip}), logger, 'geoIpAsn');
const geoIpCityP = settled(toolbox.lookupGeoIpCity({ip}), logger, 'geoIpCity');
const ipInfoP = settled(toolbox.lookupIpInfo({ip}), logger, 'ipInfoAnonymous');
const userAgentP = settled(
event.userAgent ? toolbox.analyzeUserAgent({userAgent: event.userAgent}) : null,
logger,
'userAgent',
);
const emailSyntaxP = settled(
event.email ? toolbox.analyzeEmailSyntax({email: event.email}) : null,
logger,
'emailSyntax',
);
const domainDisposableP = settled(
nicheDomain ? toolbox.checkDomainDisposable({domain: nicheDomain}) : null,
logger,
'domainDisposable',
);
const registrationsByIpP = settled(
toolbox.getRegistrationsByIp({ip, windowHours: 24}),
logger,
'registrationsByIp',
);
const suspiciousIpP = settled(toolbox.getSuspiciousIp({ip}), logger, 'suspiciousIp');
const registrationsBySubnetP = settled(
toolbox.getRegistrationsBySubnet({ip, windowHours: 24}),
logger,
'registrationsBySubnet',
);
const historicalOutcomesByIpP = settled(
toolbox.getHistoricalOutcomesByIp({ip, windowHours: 24}),
logger,
'historicalOutcomesByIp',
);
const historicalOutcomesBySubnetP = settled(
toolbox.getHistoricalOutcomesBySubnet({ip, windowHours: 24}),
logger,
'historicalOutcomesBySubnet',
);
const frequentAbuserByIpP = settled(
toolbox.getHistoricalOutcomesByIp({ip, windowHours: FREQUENT_ABUSER_WINDOW_HOURS}),
logger,
'frequentAbuserByIp',
);
const frequentAbuserBySubnetP = settled(
toolbox.getHistoricalOutcomesBySubnet({ip, windowHours: FREQUENT_ABUSER_WINDOW_HOURS}),
logger,
'frequentAbuserBySubnet',
);
const reverseDnsP = settled(toolbox.lookupReverseDns({ip}), logger, 'reverseDns');
const registrationsByEmailDomainP = settled(
nicheDomain ? toolbox.getRegistrationsByEmailDomain({domain: nicheDomain, windowHours: 168}) : null,
logger,
'registrationsByEmailDomain',
);
const registrationsByPlusAddressBaseP = settled(
plusAddressBase ? toolbox.getRegistrationsByPlusAddressBase({plusAddressBase, windowHours: 720}) : null,
logger,
'registrationsByPlusAddressBase',
);
const historicalOutcomesByEmailDomainP = settled(
nicheDomain ? toolbox.getHistoricalOutcomesByEmailDomain({domain: nicheDomain, windowHours: 168}) : null,
logger,
'historicalOutcomesByEmailDomain',
);
const domainMxP = settled(nicheDomain ? toolbox.checkMx({domain: nicheDomain}) : null, logger, 'domainMx');
const domainAgeP = settled(nicheDomain ? toolbox.checkDomainAge({domain: nicheDomain}) : null, logger, 'domainAge');
const registrationTimingP = settled(
event.timezone ? toolbox.analyzeRegistrationTiming({timezone: event.timezone}) : null,
logger,
'registrationTiming',
);
const historicalOutcomesByAsnP = geoIpAsnP.then((asn) =>
settled(
asn?.asn != null ? toolbox.getHistoricalOutcomesByAsn({asn: asn.asn, windowHours: 24}) : null,
logger,
'historicalOutcomesByAsn',
),
);
const localeGeoMatchP = geoIpCityP.then((city) =>
settled(
city?.countryIso
? toolbox.checkGeoVsLocale({
geoipCountryIso: city.countryIso,
registrationLocale: event.locale,
registrationTimezone: event.timezone,
})
: null,
logger,
'localeGeoMatch',
),
);
return {
geoIpAsn: await geoIpAsnP,
geoIpCity: await geoIpCityP,
ipInfoAnonymous: await ipInfoP,
userAgent: await userAgentP,
emailSyntax: await emailSyntaxP,
domainDisposable: await domainDisposableP,
suspiciousIp: await suspiciousIpP,
registrationsByIp: await registrationsByIpP,
registrationsBySubnet: await registrationsBySubnetP,
historicalOutcomesByIp: await historicalOutcomesByIpP,
historicalOutcomesBySubnet: await historicalOutcomesBySubnetP,
registrationsByEmailDomain: await registrationsByEmailDomainP,
registrationsByPlusAddressBase: await registrationsByPlusAddressBaseP,
historicalOutcomesByEmailDomain: await historicalOutcomesByEmailDomainP,
domainMx: await domainMxP,
domainAge: await domainAgeP,
registrationTiming: await registrationTimingP,
historicalOutcomesByAsn: await historicalOutcomesByAsnP,
localeGeoMatch: await localeGeoMatchP,
frequentAbuserByIp: await frequentAbuserByIpP,
frequentAbuserBySubnet: await frequentAbuserBySubnetP,
reverseDns: await reverseDnsP,
};
}
}