diff --git a/src/hooks/useFraudDetection.ts b/src/hooks/useFraudDetection.ts index 3f320773..879c7e0a 100644 --- a/src/hooks/useFraudDetection.ts +++ b/src/hooks/useFraudDetection.ts @@ -58,44 +58,38 @@ export function useFraudDetection( }; }, [user.id, user.name, roomId]); + const applyResult = useCallback( + (result: FraudDetectionResult) => { + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setIsBlocked((blocked) => blocked || result.blocked); + setLastCheck(result); + return result; + }, + [service, user.id], + ); + const checkJoin = useCallback((): FraudDetectionResult => { updateContext(); - const result = service.checkJoinMeeting(contextRef.current); - setFraudScore(service.getUserScore(user.id)); - setFraudEvents(service.getEvents(user.id)); - setLastCheck(result); - return result; - }, [service, updateContext, user.id]); + return applyResult(service.checkJoinMeeting(contextRef.current)); + }, [applyResult, service, updateContext]); const checkLeave = useCallback((): FraudDetectionResult => { updateContext(); - const result = service.checkLeaveMeeting(contextRef.current); - setFraudScore(service.getUserScore(user.id)); - setFraudEvents(service.getEvents(user.id)); - setLastCheck(result); - return result; - }, [service, updateContext, user.id]); + return applyResult(service.checkLeaveMeeting(contextRef.current)); + }, [applyResult, service, updateContext]); const checkStartCall = useCallback((): FraudDetectionResult => { updateContext(); - const result = service.checkStartCall(contextRef.current); - setFraudScore(service.getUserScore(user.id)); - setFraudEvents(service.getEvents(user.id)); - setIsBlocked(result.blocked); - setLastCheck(result); - return result; - }, [service, updateContext, user.id]); + return applyResult(service.checkStartCall(contextRef.current)); + }, [applyResult, service, updateContext]); const checkScreenShare = useCallback( (enabled: boolean): FraudDetectionResult => { updateContext(); - const result = service.checkScreenShare(contextRef.current, enabled); - setFraudScore(service.getUserScore(user.id)); - setFraudEvents(service.getEvents(user.id)); - setLastCheck(result); - return result; + return applyResult(service.checkScreenShare(contextRef.current, enabled)); }, - [service, updateContext, user.id], + [applyResult, service, updateContext], ); const checkAccess = useCallback((): ConferenceAccessCheck => { @@ -106,12 +100,8 @@ export function useFraudDetection( const checkMeetingBombing = useCallback((): FraudDetectionResult => { updateContext(); - const result = service.checkMeetingBombing(contextRef.current); - setFraudScore(service.getUserScore(user.id)); - setFraudEvents(service.getEvents(user.id)); - setLastCheck(result); - return result; - }, [service, updateContext, user.id]); + return applyResult(service.checkMeetingBombing(contextRef.current)); + }, [applyResult, service, updateContext]); const resetScore = useCallback(() => { service.resetUserScore(user.id); diff --git a/src/services/__tests__/bundleSecurityContext.test.ts b/src/services/__tests__/bundleSecurityContext.test.ts index ad805999..d975d37c 100644 --- a/src/services/__tests__/bundleSecurityContext.test.ts +++ b/src/services/__tests__/bundleSecurityContext.test.ts @@ -136,6 +136,29 @@ describe('BundleSecurityContext (#409)', () => { }); }); + describe('scored rules', () => { + it('returns weighted findings while preserving audit violations', () => { + ctx.setDefaultPolicy({ trustLevel: 'trusted', requiredIntegrity: true }); + const chunk = makeChunk({ id: 'risky', name: 'risky', size: 600 }); + const result = ctx.auditScored(chunk); + + expect(result.violations).toEqual(ctx.audit(chunk)); + expect(result.matchedRules).toEqual(['integrity', 'size', 'trust']); + expect(result.score).toBe(90); + }); + + it('caps the aggregate score at 100', () => { + ctx.setDefaultPolicy({ + trustLevel: 'trusted', + requiredIntegrity: true, + maxSize: 10, + allowedOrigins: [/^https:\/\/cdn\.example\.com\//], + }); + const result = ctx.auditScored(makeChunk({ id: 'risky', size: 100 })); + expect(result.score).toBe(100); + }); + }); + describe('singleton', () => { it('exports a shared instance', () => { expect(bundleSecurityContext).toBeInstanceOf(BundleSecurityContext); diff --git a/src/services/bundleSecurityContext.ts b/src/services/bundleSecurityContext.ts index 41d906c3..a104e385 100644 --- a/src/services/bundleSecurityContext.ts +++ b/src/services/bundleSecurityContext.ts @@ -35,6 +35,22 @@ export interface ChunkSecurityMetadata { trustLevel: TrustLevel; } +export interface SecurityRule { + id: string; + weight: number; + evaluate: ( + chunk: BundleChunk, + metadata: ChunkSecurityMetadata, + policy: ChunkSecurityPolicy, + ) => string | undefined; +} + +export interface ScoredSecurityAudit { + score: number; + violations: string[]; + matchedRules: string[]; +} + const DEFAULT_POLICY: ChunkSecurityPolicy = { requiredIntegrity: true, maxSize: 500, // KB @@ -51,6 +67,51 @@ const TRUST_RANK: Record = { untrusted: 2, }; +const SECURITY_RULES: SecurityRule[] = [ + { + id: 'integrity', + weight: 30, + evaluate: (chunk, metadata, policy) => + policy.requiredIntegrity && !(metadata.integrity?.trim() ?? '') + ? `Chunk "${chunk.name}" is missing SRI integrity attribute.` + : undefined, + }, + { + id: 'origin', + weight: 25, + evaluate: (chunk, metadata, policy) => { + if (!policy.allowedOrigins || policy.allowedOrigins.length === 0) return undefined; + const origin = metadata.origin?.trim() ?? ''; + if (!origin) return `Chunk "${chunk.name}" has no declared origin.`; + if (!policy.allowedOrigins.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(origin); + })) { + return `Chunk "${chunk.name}" origin "${origin}" is not in the allow-list.`; + } + return undefined; + }, + }, + { + id: 'size', + weight: 20, + evaluate: (chunk, _metadata, policy) => + typeof policy.maxSize === 'number' && + typeof chunk.size === 'number' && + chunk.size > policy.maxSize + ? `Chunk "${chunk.name}" size ${chunk.size}KB exceeds maximum ${policy.maxSize}KB.` + : undefined, + }, + { + id: 'trust', + weight: 40, + evaluate: (chunk, metadata, policy) => + TRUST_RANK[metadata.trustLevel] > TRUST_RANK[policy.trustLevel] + ? `Chunk "${chunk.name}" declared trust level "${metadata.trustLevel}" is below required "${policy.trustLevel}".` + : undefined, + }, +]; + /** * Holds the effective security policy for every registered chunk and * exposes an `audit()` helper used by the bundle optimizer's health report. @@ -105,41 +166,34 @@ export class BundleSecurityContext { * Returns a list of human-readable violations (empty = compliant). */ audit(chunk: BundleChunk): string[] { + return this.auditScored(chunk).violations; + } + + auditScored(chunk: BundleChunk): ScoredSecurityAudit { const policy = this.getEffectivePolicy(chunk.id); - const meta = this.metadata.get(chunk.id) ?? { trustLevel: 'untrusted' as const }; + const metadata = this.metadata.get(chunk.id) ?? { trustLevel: 'untrusted' as const }; const violations: string[] = []; + const matchedRules: string[] = []; - if (policy.requiredIntegrity) { - const integrity = meta.integrity?.trim() ?? ''; - if (integrity.length === 0) { - violations.push(`Chunk "${chunk.name}" is missing SRI integrity attribute.`); + for (const rule of SECURITY_RULES) { + const violation = rule.evaluate(chunk, metadata, policy); + if (violation) { + violations.push(violation); + matchedRules.push(rule.id); } } - if (policy.allowedOrigins && policy.allowedOrigins.length > 0) { - const origin = meta.origin?.trim() ?? ''; - if (origin.length === 0) { - violations.push(`Chunk "${chunk.name}" has no declared origin.`); - } else if (!policy.allowedOrigins.some((re) => re.test(origin))) { - violations.push(`Chunk "${chunk.name}" origin "${origin}" is not in the allow-list.`); - } - } - - if (typeof policy.maxSize === 'number' && typeof chunk.size === 'number') { - if (chunk.size > policy.maxSize) { - violations.push( - `Chunk "${chunk.name}" size ${chunk.size}KB exceeds maximum ${policy.maxSize}KB.`, - ); - } - } - - if (TRUST_RANK[meta.trustLevel] > TRUST_RANK[policy.trustLevel]) { - violations.push( - `Chunk "${chunk.name}" declared trust level "${meta.trustLevel}" is below required "${policy.trustLevel}".`, - ); - } - - return violations; + return { + violations, + matchedRules, + score: Math.min( + 100, + matchedRules.reduce( + (total, ruleId) => total + (SECURITY_RULES.find((rule) => rule.id === ruleId)?.weight ?? 0), + 0, + ), + ), + }; } /**