FRE-4499: Fix security review findings (S01-S06)
- S01 (High): Pre-compile regex patterns in RuleEngine.loadActiveRules() and
cache them; eliminate per-evaluation RegExp construction in rule-engine.ts
and spamshield.service.ts (ReDoS mitigation)
- S02 (High): SMS classifier now accepts optional senderPhoneNumber via
SmsClassificationContext; reputation check uses actual sender instead of
hardcoded 'placeholder'
- S03 (Medium): AlertServer (services/spamshield) now enforces JWT auth,
origin allowlist, and max client limit on WebSocket connections
- S04 (Medium): hashPhoneNumber() uses SHA-256 (crypto.createHash) instead
of reversible hex encoding (Buffer.toString('hex'))
- S05 (Medium): DecisionEngine.evaluate() wraps evaluation in Promise.race
with configurable evaluationTimeout; returns fallback decision on timeout
- S06 (Medium): CarrierFactory.getAllCarriers() is now async and properly
awaits isHealthy() promises instead of returning raw Promise objects
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -116,8 +116,23 @@ export class DecisionEngine {
|
||||
async evaluate(context: DecisionContext): Promise<DecisionResult> {
|
||||
const startTime = Date.now();
|
||||
const reqId = context.requestId ?? 'unknown';
|
||||
|
||||
try {
|
||||
const fallback: DecisionResult = {
|
||||
decision: this.config.fallbackDecision,
|
||||
confidence: 0.5,
|
||||
reasons: ['Fallback decision due to evaluation timeout'],
|
||||
fallbackDecision: this.config.fallbackDecision,
|
||||
scoring: {
|
||||
reputationScore: 0.5,
|
||||
ruleScore: 0.5,
|
||||
behavioralScore: 0.5,
|
||||
userHistoryScore: 0.5,
|
||||
totalScore: 0.5,
|
||||
},
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
|
||||
const evaluation = (async () => {
|
||||
const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([
|
||||
this.calculateReputationScore(context.cachedReputation),
|
||||
this.calculateRuleScore(context.ruleMatches),
|
||||
@@ -151,25 +166,25 @@ export class DecisionEngine {
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
})();
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
evaluation,
|
||||
new Promise<DecisionResult>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[DecisionEngine] [${reqId}] Evaluation timeout after ${this.config.evaluationTimeout}ms`);
|
||||
resolve(fallback);
|
||||
}, this.config.evaluationTimeout);
|
||||
}),
|
||||
]);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error);
|
||||
|
||||
if (this.config.fallbackOnTimeout) {
|
||||
return {
|
||||
decision: this.config.fallbackDecision,
|
||||
confidence: 0.5,
|
||||
reasons: ['Fallback decision due to evaluation error'],
|
||||
fallbackDecision: this.config.fallbackDecision,
|
||||
scoring: {
|
||||
reputationScore: 0.5,
|
||||
ruleScore: 0.5,
|
||||
behavioralScore: 0.5,
|
||||
userHistoryScore: 0.5,
|
||||
totalScore: 0.5,
|
||||
},
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
return { ...fallback, reasons: ['Fallback decision due to evaluation error'] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { PrismaClient, SpamRule } from '@prisma/client';
|
||||
import { generateRequestId } from '@shieldai/types';
|
||||
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
|
||||
|
||||
export interface CompiledRule {
|
||||
rule: SpamRule;
|
||||
compiledPattern: RegExp;
|
||||
compiledCaseInsensitive?: RegExp;
|
||||
}
|
||||
|
||||
export interface RuleMatch {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
@@ -25,10 +31,10 @@ const DEFAULT_CONFIG: Required<RuleEngineConfig> = {
|
||||
|
||||
export class RuleEngine {
|
||||
private readonly config: Required<RuleEngineConfig>;
|
||||
private numberPatternRules: SpamRule[] = [];
|
||||
private behavioralRules: SpamRule[] = [];
|
||||
private contentRules: SpamRule[] = [];
|
||||
private allRules: SpamRule[] = [];
|
||||
private numberPatternRules: CompiledRule[] = [];
|
||||
private behavioralRules: CompiledRule[] = [];
|
||||
private contentRules: CompiledRule[] = [];
|
||||
private allRules: CompiledRule[] = [];
|
||||
private lastLoadTime: Date | null = null;
|
||||
private readonly prisma: PrismaClient;
|
||||
|
||||
@@ -52,11 +58,17 @@ export class RuleEngine {
|
||||
orderBy: { priority: 'desc' },
|
||||
});
|
||||
|
||||
const validatedRules: SpamRule[] = [];
|
||||
const compiledRules: CompiledRule[] = [];
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
validatedRules.push(rule);
|
||||
const compiledPattern = new RegExp(rule.pattern);
|
||||
const compiledCaseInsensitive = new RegExp(rule.pattern, 'i');
|
||||
compiledRules.push({
|
||||
rule,
|
||||
compiledPattern,
|
||||
compiledCaseInsensitive,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk: ${error.reason}, skipping`);
|
||||
@@ -66,10 +78,10 @@ export class RuleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
this.allRules = validatedRules;
|
||||
this.numberPatternRules = validatedRules.filter(r => (r as any).category === 'number_pattern');
|
||||
this.behavioralRules = validatedRules.filter(r => (r as any).category === 'behavioral');
|
||||
this.contentRules = validatedRules.filter(r => (r as any).category === 'content');
|
||||
this.allRules = compiledRules;
|
||||
this.numberPatternRules = compiledRules.filter(r => (r.rule as any).category === 'number_pattern');
|
||||
this.behavioralRules = compiledRules.filter(r => (r.rule as any).category === 'behavioral');
|
||||
this.contentRules = compiledRules.filter(r => (r.rule as any).category === 'content');
|
||||
this.lastLoadTime = now;
|
||||
}
|
||||
|
||||
@@ -80,26 +92,20 @@ export class RuleEngine {
|
||||
|
||||
const matches: RuleMatch[] = [];
|
||||
|
||||
for (const rule of this.allRules) {
|
||||
for (const compiled of this.allRules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
const pattern = new RegExp(rule.pattern);
|
||||
if (pattern.test(phoneNumber)) {
|
||||
if (compiled.compiledPattern.test(phoneNumber)) {
|
||||
matches.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
pattern: rule.pattern,
|
||||
score: (rule as any).score,
|
||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
||||
ruleId: compiled.rule.id,
|
||||
ruleName: compiled.rule.name,
|
||||
pattern: compiled.rule.pattern,
|
||||
score: (compiled.rule as any).score,
|
||||
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||
matchedAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
|
||||
} else {
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
|
||||
}
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Evaluation error for rule ${compiled.rule.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,26 +119,20 @@ export class RuleEngine {
|
||||
|
||||
const matches: RuleMatch[] = [];
|
||||
|
||||
for (const rule of this.contentRules) {
|
||||
for (const compiled of this.contentRules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
const pattern = new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(smsBody)) {
|
||||
if (compiled.compiledCaseInsensitive!.test(smsBody)) {
|
||||
matches.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
pattern: rule.pattern,
|
||||
score: (rule as any).score,
|
||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
||||
ruleId: compiled.rule.id,
|
||||
ruleName: compiled.rule.name,
|
||||
pattern: compiled.rule.pattern,
|
||||
score: (compiled.rule as any).score,
|
||||
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||
matchedAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
|
||||
} else {
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
|
||||
}
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] SMS evaluation error for rule ${compiled.rule.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,19 +140,19 @@ export class RuleEngine {
|
||||
}
|
||||
|
||||
getNumberPatternRules(): SpamRule[] {
|
||||
return [...this.numberPatternRules];
|
||||
return this.numberPatternRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getBehavioralRules(): SpamRule[] {
|
||||
return [...this.behavioralRules];
|
||||
return this.behavioralRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getContentRules(): SpamRule[] {
|
||||
return [...this.contentRules];
|
||||
return this.contentRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getAllRules(): SpamRule[] {
|
||||
return [...this.allRules];
|
||||
return this.allRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
async refreshRules(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user