437 lines
No EOL
13 KiB
TypeScript
437 lines
No EOL
13 KiB
TypeScript
// =============================================================================
|
|
// THREAT SCORING ENGINE (TypeScript)
|
|
// =============================================================================
|
|
|
|
import { STATIC_WHITELIST, type ThreatThresholds, type SignalWeights } from './constants.js';
|
|
import { type IncomingHttpHeaders } from 'http';
|
|
import type { NetworkRequest } from '../network.js';
|
|
import * as logs from '../logs.js';
|
|
import { performance } from 'perf_hooks';
|
|
|
|
// Simple utility functions
|
|
function performSecurityChecks(ip: string): string {
|
|
if (typeof ip !== 'string' || ip.length === 0 || ip.length > 45) {
|
|
throw new Error('Invalid IP address');
|
|
}
|
|
return ip.trim();
|
|
}
|
|
|
|
function normalizeMetricValue(value: number, min: number, max: number): number {
|
|
if (typeof value !== 'number' || isNaN(value)) return 0;
|
|
if (max <= min) return value >= max ? 1 : 0;
|
|
const clampedValue = Math.max(min, Math.min(max, value));
|
|
return (clampedValue - min) / (max - min);
|
|
}
|
|
|
|
// =============================================================================
|
|
// TYPE DEFINITIONS
|
|
// =============================================================================
|
|
|
|
export interface ThreatScore {
|
|
readonly totalScore: number;
|
|
readonly confidence: number;
|
|
readonly riskLevel: 'allow' | 'challenge' | 'block';
|
|
readonly components: {
|
|
readonly behaviorScore: number;
|
|
readonly contentScore: number;
|
|
readonly networkScore: number;
|
|
readonly anomalyScore: number;
|
|
};
|
|
readonly signalsTriggered: readonly string[];
|
|
readonly normalizedFeatures: Record<string, number>;
|
|
readonly processingTimeMs: number;
|
|
}
|
|
|
|
export interface ThreatScoringConfig {
|
|
readonly enabled: boolean;
|
|
readonly thresholds: ThreatThresholds;
|
|
readonly signalWeights: SignalWeights;
|
|
readonly enableBotVerification?: boolean;
|
|
readonly enableGeoAnalysis?: boolean;
|
|
readonly enableBehaviorAnalysis?: boolean;
|
|
readonly enableContentAnalysis?: boolean;
|
|
readonly logDetailedScores?: boolean;
|
|
}
|
|
|
|
interface RequestMetadata {
|
|
readonly startTime: number;
|
|
readonly ip: string;
|
|
readonly userAgent?: string;
|
|
readonly method: string;
|
|
readonly path: string;
|
|
readonly headers: IncomingHttpHeaders;
|
|
readonly body?: string;
|
|
readonly sessionId?: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// THREAT SCORING ENGINE
|
|
// =============================================================================
|
|
|
|
export class ThreatScorer {
|
|
private readonly config: ThreatScoringConfig;
|
|
|
|
constructor(config: ThreatScoringConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Performs comprehensive threat scoring on a request
|
|
*/
|
|
public async scoreRequest(request: NetworkRequest): Promise<ThreatScore> {
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
// Check if scoring is enabled
|
|
if (!this.config.enabled) {
|
|
return this.createAllowScore(startTime);
|
|
}
|
|
|
|
// Extract request metadata
|
|
const metadata = this.extractRequestMetadata(request, startTime);
|
|
|
|
// Validate input and perform security checks
|
|
performSecurityChecks(metadata.ip);
|
|
|
|
// Check static whitelist (quick path for assets)
|
|
if (this.isWhitelisted(metadata.path)) {
|
|
return this.createAllowScore(startTime);
|
|
}
|
|
|
|
// Perform threat analysis
|
|
const score = this.performBasicThreatAnalysis(metadata);
|
|
|
|
return score;
|
|
|
|
} catch (error) {
|
|
logs.error('threat-scorer', `Error scoring request: ${error}`);
|
|
return this.createErrorScore(startTime);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract basic metadata from request
|
|
*/
|
|
private extractRequestMetadata(request: NetworkRequest, startTime: number): RequestMetadata {
|
|
const headers = request.headers || {};
|
|
const userAgent = this.extractUserAgent(headers);
|
|
const ip = this.extractClientIP(request);
|
|
|
|
return {
|
|
startTime,
|
|
ip,
|
|
userAgent,
|
|
method: (request as any).method || 'GET',
|
|
path: this.extractPath(request),
|
|
headers: headers as IncomingHttpHeaders,
|
|
body: (request as any).body,
|
|
sessionId: this.extractSessionId(headers)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract user agent from headers
|
|
*/
|
|
private extractUserAgent(headers: any): string {
|
|
if (headers && typeof headers.get === 'function') {
|
|
return headers.get('user-agent') || '';
|
|
}
|
|
if (headers && typeof headers === 'object') {
|
|
return headers['user-agent'] || '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Extract client IP from request
|
|
*/
|
|
private extractClientIP(request: NetworkRequest): string {
|
|
// Try common IP extraction methods
|
|
const headers = request.headers;
|
|
if (headers) {
|
|
if (typeof headers.get === 'function') {
|
|
return headers.get('x-forwarded-for') ||
|
|
headers.get('x-real-ip') ||
|
|
headers.get('cf-connecting-ip') || '127.0.0.1';
|
|
}
|
|
if (typeof headers === 'object') {
|
|
const h = headers as any;
|
|
return h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip'] || '127.0.0.1';
|
|
}
|
|
}
|
|
return '127.0.0.1';
|
|
}
|
|
|
|
/**
|
|
* Extract path from request
|
|
*/
|
|
private extractPath(request: NetworkRequest): string {
|
|
if ((request as any).url) {
|
|
try {
|
|
const url = new URL((request as any).url, 'http://localhost');
|
|
return url.pathname;
|
|
} catch {
|
|
return (request as any).url || '/';
|
|
}
|
|
}
|
|
return '/';
|
|
}
|
|
|
|
/**
|
|
* Extract session ID from headers
|
|
*/
|
|
private extractSessionId(headers: any): string | undefined {
|
|
// Basic session ID extraction from cookies
|
|
if (headers && headers.cookie) {
|
|
const cookies = headers.cookie.split(';');
|
|
for (const cookie of cookies) {
|
|
const [name, value] = cookie.trim().split('=');
|
|
if (name && name.toLowerCase().includes('session')) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Check if path is in static whitelist
|
|
*/
|
|
private isWhitelisted(path: string): boolean {
|
|
// Check static file extensions
|
|
for (const ext of STATIC_WHITELIST.extensions) {
|
|
if (path.endsWith(ext)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check whitelisted paths
|
|
for (const whitelistPath of STATIC_WHITELIST.paths) {
|
|
if (path.startsWith(whitelistPath)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check patterns
|
|
for (const pattern of STATIC_WHITELIST.patterns) {
|
|
if (pattern.test(path)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Perform basic threat analysis (simplified version)
|
|
*/
|
|
private performBasicThreatAnalysis(metadata: RequestMetadata): ThreatScore {
|
|
const startTime = performance.now();
|
|
const signalsTriggered: string[] = [];
|
|
let totalScore = 0;
|
|
|
|
const components = {
|
|
networkScore: 0,
|
|
behaviorScore: 0,
|
|
contentScore: 0,
|
|
anomalyScore: 0
|
|
};
|
|
|
|
// Basic checks
|
|
if (!metadata.userAgent || metadata.userAgent.length === 0) {
|
|
components.anomalyScore += this.config.signalWeights.MISSING_UA?.weight || 10;
|
|
signalsTriggered.push('MISSING_UA');
|
|
}
|
|
|
|
// WAF signal integration - use WAF results if available
|
|
const wafSignals = this.extractWAFSignals(metadata);
|
|
if (wafSignals) {
|
|
const wafScore = this.calculateWAFScore(wafSignals, signalsTriggered);
|
|
components.contentScore += wafScore;
|
|
}
|
|
|
|
totalScore = components.networkScore + components.behaviorScore +
|
|
components.contentScore + components.anomalyScore;
|
|
|
|
// Determine risk level
|
|
const riskLevel = this.determineRiskLevel(totalScore);
|
|
|
|
// Calculate confidence (simplified)
|
|
const confidence = Math.min(0.8, signalsTriggered.length * 0.2 + 0.3);
|
|
|
|
const processingTimeMs = performance.now() - startTime;
|
|
|
|
return {
|
|
totalScore,
|
|
confidence,
|
|
riskLevel,
|
|
components,
|
|
signalsTriggered,
|
|
normalizedFeatures: {
|
|
networkRisk: normalizeMetricValue(components.networkScore, 0, 100),
|
|
behaviorRisk: normalizeMetricValue(components.behaviorScore, 0, 100),
|
|
contentRisk: normalizeMetricValue(components.contentScore, 0, 100),
|
|
anomalyRisk: normalizeMetricValue(components.anomalyScore, 0, 100)
|
|
},
|
|
processingTimeMs
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract WAF signals from request metadata
|
|
*/
|
|
private extractWAFSignals(metadata: RequestMetadata): Record<string, unknown> | null {
|
|
// WAF signals are attached to the request object by WAF middleware
|
|
// Try multiple ways to access them depending on request type
|
|
const request = metadata as any;
|
|
|
|
// Express-style: res.locals.wafSignals (if request has res)
|
|
if (request.res?.locals?.wafSignals) {
|
|
return request.res.locals.wafSignals;
|
|
}
|
|
|
|
// Direct attachment: request.wafSignals
|
|
if (request.wafSignals) {
|
|
return request.wafSignals;
|
|
}
|
|
|
|
// Headers may contain WAF detection flags
|
|
if (metadata.headers) {
|
|
const wafHeader = metadata.headers['x-waf-signals'] || metadata.headers['X-WAF-Signals'];
|
|
if (wafHeader && typeof wafHeader === 'string') {
|
|
try {
|
|
return JSON.parse(wafHeader);
|
|
} catch {
|
|
// Invalid JSON, ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Calculate threat score from WAF signals
|
|
*/
|
|
private calculateWAFScore(wafSignals: Record<string, unknown>, signalsTriggered: string[]): number {
|
|
let score = 0;
|
|
|
|
// Map WAF detections to configured signal weights
|
|
if (wafSignals.sqlInjection || wafSignals.sql_injection) {
|
|
score += this.config.signalWeights.SQL_INJECTION?.weight || 80;
|
|
signalsTriggered.push('SQL_INJECTION');
|
|
}
|
|
|
|
if (wafSignals.xss || wafSignals.xssAttempt) {
|
|
score += this.config.signalWeights.XSS_ATTEMPT?.weight || 85;
|
|
signalsTriggered.push('XSS_ATTEMPT');
|
|
}
|
|
|
|
if (wafSignals.commandInjection || wafSignals.command_injection) {
|
|
score += this.config.signalWeights.COMMAND_INJECTION?.weight || 95;
|
|
signalsTriggered.push('COMMAND_INJECTION');
|
|
}
|
|
|
|
if (wafSignals.pathTraversal || wafSignals.path_traversal) {
|
|
score += this.config.signalWeights.PATH_TRAVERSAL?.weight || 70;
|
|
signalsTriggered.push('PATH_TRAVERSAL');
|
|
}
|
|
|
|
// Handle unverified bot detection - CRITICAL for fake bots
|
|
if (wafSignals.unverified_bot) {
|
|
score += 50; // High penalty for fake bot user agents
|
|
signalsTriggered.push('UNVERIFIED_BOT');
|
|
}
|
|
|
|
// Handle WAF attack tool detection in user agents
|
|
const detectedAttacks = wafSignals.detected_attacks;
|
|
if (Array.isArray(detectedAttacks)) {
|
|
if (detectedAttacks.includes('attack_tool_user_agent')) {
|
|
score += this.config.signalWeights.ATTACK_TOOL_UA?.weight || 30;
|
|
signalsTriggered.push('ATTACK_TOOL_UA');
|
|
}
|
|
|
|
// Additional detection for unverified bots via attack list
|
|
if (detectedAttacks.includes('unverified_bot')) {
|
|
score += 50;
|
|
signalsTriggered.push('UNVERIFIED_BOT');
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
/**
|
|
* Determines risk level based on score and configured thresholds
|
|
*/
|
|
private determineRiskLevel(score: number): 'allow' | 'challenge' | 'block' {
|
|
if (score <= this.config.thresholds.ALLOW) return 'allow';
|
|
if (score <= this.config.thresholds.CHALLENGE) return 'challenge';
|
|
return 'block';
|
|
}
|
|
|
|
/**
|
|
* Creates an allow score for whitelisted or disabled requests
|
|
*/
|
|
private createAllowScore(startTime: number): ThreatScore {
|
|
return {
|
|
totalScore: 0,
|
|
confidence: 1.0,
|
|
riskLevel: 'allow',
|
|
components: {
|
|
behaviorScore: 0,
|
|
contentScore: 0,
|
|
networkScore: 0,
|
|
anomalyScore: 0
|
|
},
|
|
signalsTriggered: [],
|
|
normalizedFeatures: {},
|
|
processingTimeMs: performance.now() - startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates an error score when threat analysis fails
|
|
*/
|
|
private createErrorScore(startTime: number): ThreatScore {
|
|
return {
|
|
totalScore: 0,
|
|
confidence: 0,
|
|
riskLevel: 'allow', // Fail open
|
|
components: {
|
|
behaviorScore: 0,
|
|
contentScore: 0,
|
|
networkScore: 0,
|
|
anomalyScore: 0
|
|
},
|
|
signalsTriggered: ['ERROR'],
|
|
normalizedFeatures: {},
|
|
processingTimeMs: performance.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates and configures a threat scorer instance
|
|
*/
|
|
export function createThreatScorer(config: ThreatScoringConfig): ThreatScorer {
|
|
return new ThreatScorer(config);
|
|
}
|
|
|
|
// Default threat scorer for convenience (requires configuration)
|
|
let defaultScorer: ThreatScorer | null = null;
|
|
|
|
export function configureDefaultThreatScorer(config: ThreatScoringConfig): void {
|
|
defaultScorer = new ThreatScorer(config);
|
|
}
|
|
|
|
export const threatScorer = {
|
|
scoreRequest: async (request: NetworkRequest): Promise<ThreatScore> => {
|
|
if (!defaultScorer) {
|
|
throw new Error('Default threat scorer not configured. Call configureDefaultThreatScorer() first.');
|
|
}
|
|
return defaultScorer.scoreRequest(request);
|
|
}
|
|
};
|
|
|
|
|