Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 21 additions & 31 deletions src/hooks/useFraudDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions src/services/__tests__/bundleSecurityContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
112 changes: 83 additions & 29 deletions src/services/bundleSecurityContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +67,51 @@ const TRUST_RANK: Record<TrustLevel, number> = {
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.
Expand Down Expand Up @@ -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,
),
),
};
}

/**
Expand Down
Loading