diff --git a/src/services/__tests__/bundleSecurityContext.test.ts b/src/services/__tests__/bundleSecurityContext.test.ts new file mode 100644 index 00000000..ad805999 --- /dev/null +++ b/src/services/__tests__/bundleSecurityContext.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + BundleSecurityContext, + bundleSecurityContext, + type ChunkSecurityPolicy, +} from '../bundleSecurityContext'; +import type { BundleChunk } from '../bundleOptimizer'; + +const makeChunk = (over: Partial = {}): BundleChunk => ({ + id: 'default-id', + name: 'default-chunk', + size: 100, + priority: 'medium', + ...over, +}); + +describe('BundleSecurityContext (#409)', () => { + let ctx: BundleSecurityContext; + + beforeEach(() => { + ctx = new BundleSecurityContext(); + }); + + describe('default policy', () => { + it('requires SRI by default', () => { + const violations = ctx.audit(makeChunk({ id: 'a', name: 'a' })); + expect(violations).toContain('Chunk "a" is missing SRI integrity attribute.'); + }); + + it('is satisfied when an integrity hash is supplied', () => { + ctx.attachMetadata('a', { + integrity: 'sha384-abc', + trustLevel: 'verified', + }); + expect(ctx.audit(makeChunk({ id: 'a', name: 'a' }))).toEqual([]); + }); + }); + + describe('per-chunk override', () => { + it('uses per-chunk policy over default', () => { + ctx.setPolicyFor('trusted', { + trustLevel: 'trusted', + requiredIntegrity: false, + }); + ctx.attachMetadata('trusted', { trustLevel: 'trusted' }); + // Lax on integrity, trust level matches policy + const v = ctx.audit(makeChunk({ id: 'trusted', name: 'trusted' })); + expect(v).toEqual([]); + // Default still requires integrity for other chunks + expect(ctx.audit(makeChunk({ id: 'other', name: 'other' })).length).toBeGreaterThan(0); + }); + + it('falls back to untrusted when no metadata is attached', () => { + // Secure-by-default: a chunk with no metadata is treated as untrusted + const v = ctx.audit(makeChunk({ id: 'a', name: 'a' })); + expect(v.some((s) => s.includes('trust level'))).toBe(true); + }); + + it('detachMetadata clears the chunk security state', () => { + ctx.attachMetadata('a', { trustLevel: 'verified' }); + ctx.detachMetadata('a'); + expect(ctx.getMetadata('a')).toBeUndefined(); + // After detach, chunk falls back to untrusted + const v = ctx.audit(makeChunk({ id: 'a', name: 'a' })); + expect(v.some((s) => s.includes('trust level'))).toBe(true); + }); + + it('attachMetadata overwrites prior metadata for the same id', () => { + ctx.attachMetadata('a', { trustLevel: 'verified', origin: 'https://x.example.com/a.js' }); + ctx.attachMetadata('a', { trustLevel: 'trusted' }); + const m = ctx.getMetadata('a')!; + expect(m.trustLevel).toBe('trusted'); + expect(m.origin).toBeUndefined(); + }); + }); + + describe('origin allow-list', () => { + it('reports unknown origins', () => { + ctx.setDefaultPolicy({ + trustLevel: 'verified', + requiredIntegrity: false, + allowedOrigins: [/^https:\/\/cdn\.example\.com\//], + }); + ctx.attachMetadata('a', { trustLevel: 'verified' }); + const v = ctx.audit(makeChunk({ id: 'a', name: 'a' })); + expect(v).toContain('Chunk "a" has no declared origin.'); + }); + + it('passes when origin matches', () => { + ctx.setDefaultPolicy({ + trustLevel: 'verified', + requiredIntegrity: false, + allowedOrigins: [/^https:\/\/cdn\.example\.com\//], + }); + ctx.attachMetadata('a', { + trustLevel: 'verified', + origin: 'https://cdn.example.com/lib.js', + }); + expect(ctx.audit(makeChunk({ id: 'a', name: 'a' }))).toEqual([]); + }); + }); + + describe('size limit', () => { + it('flags oversized chunks', () => { + ctx.setDefaultPolicy({ trustLevel: 'verified', requiredIntegrity: false }); + ctx.attachMetadata('a', { trustLevel: 'verified' }); + const v = ctx.audit(makeChunk({ id: 'a', name: 'a', size: 600 })); + expect(v[0]).toMatch(/exceeds maximum 500KB/); + }); + }); + + describe('trust level', () => { + it('flags chunk trust below required', () => { + ctx.setDefaultPolicy({ + trustLevel: 'trusted', + requiredIntegrity: false, + }); + ctx.attachMetadata('a', { trustLevel: 'untrusted' }); + const v = ctx.audit(makeChunk({ id: 'a', name: 'a', size: 10 })); + expect(v).toContain( + 'Chunk "a" declared trust level "untrusted" is below required "trusted".', + ); + }); + }); + + describe('auditBatch', () => { + it('returns only chunks with violations', () => { + ctx.setDefaultPolicy({ trustLevel: 'verified', requiredIntegrity: false }); + ctx.attachMetadata('good', { trustLevel: 'verified' }); + const audit = ctx.auditBatch([ + makeChunk({ id: 'good', name: 'good', size: 50 }), + makeChunk({ id: 'bad', name: 'bad', size: 9999 }), + ]); + expect(audit.has('good')).toBe(false); + expect(audit.has('bad')).toBe(true); + }); + }); + + describe('singleton', () => { + it('exports a shared instance', () => { + expect(bundleSecurityContext).toBeInstanceOf(BundleSecurityContext); + }); + }); + + describe('policy immutability', () => { + it('getPolicyFor returns a defensive copy', () => { + const p: ChunkSecurityPolicy = { trustLevel: 'verified', requiredIntegrity: false }; + ctx.setPolicyFor('a', p); + const fetched = ctx.getPolicyFor('a')!; + fetched.requiredIntegrity = true; + expect(ctx.getPolicyFor('a')!.requiredIntegrity).toBe(false); + }); + + it('getEffectivePolicy returns a defensive copy', () => { + const fetched = ctx.getEffectivePolicy('a'); + fetched.requiredIntegrity = false; + expect(ctx.getEffectivePolicy('a').requiredIntegrity).toBe(true); + }); + }); +}); diff --git a/src/services/bundleOptimizer.ts b/src/services/bundleOptimizer.ts index 9a11290e..8a284a4a 100644 --- a/src/services/bundleOptimizer.ts +++ b/src/services/bundleOptimizer.ts @@ -1,7 +1,13 @@ /** * Service for bundle analysis and dynamic loading optimization. + * + * Includes a Security Context integration that enforces integrity, + * origin allow-listing, size limits and trust levels per chunk + * (see #409: Bundle Optimization: Security Context). */ +import { bundleSecurityContext, type ChunkSecurityMetadata } from './bundleSecurityContext'; + export interface BundleChunk { id: string; name: string; @@ -9,14 +15,30 @@ export interface BundleChunk { priority: 'high' | 'medium' | 'low'; } +/** Optional security metadata that can be supplied when registering a chunk. */ +export interface BundleChunkRegistration extends BundleChunk { + security?: ChunkSecurityMetadata; +} + class BundleOptimizer { private chunks: Map = new Map(); /** - * Registers a chunk for monitoring. + * Registers a chunk for monitoring. Optional security metadata is forwarded + * to the shared BundleSecurityContext so the chunk participates in audits. */ - registerChunk(chunk: BundleChunk) { + registerChunk(chunk: BundleChunk | BundleChunkRegistration) { this.chunks.set(chunk.id, chunk); + const registration = chunk as BundleChunkRegistration; + if (registration.security) { + bundleSecurityContext.attachMetadata(chunk.id, registration.security); + } + } + + /** Detaches and forgets a chunk. Also clears its security metadata. */ + unregisterChunk(chunkId: string) { + this.chunks.delete(chunkId); + bundleSecurityContext.detachMetadata(chunkId); } /** diff --git a/src/services/bundleSecurityContext.ts b/src/services/bundleSecurityContext.ts new file mode 100644 index 00000000..41d906c3 --- /dev/null +++ b/src/services/bundleSecurityContext.ts @@ -0,0 +1,160 @@ +/** + * Bundle Security Context (#409) + * + * Provides per-chunk security policies for the bundle optimizer: + * - Subresource Integrity (SRI) enforcement + * - Origin allow-listing (regex) + * - Maximum chunk size + * - Trust-level tagging + * + * The context is fully decoupled from the bundle registry so it can be + * unit-tested in isolation and consumed by any bundle-loading pipeline. + */ + +import type { BundleChunk } from './bundleOptimizer'; + +/** Trust level required to load a chunk. */ +export type TrustLevel = 'trusted' | 'verified' | 'untrusted'; + +/** Per-chunk security policy applied during audit. */ +export interface ChunkSecurityPolicy { + /** Regex patterns; if set, chunk.origin must match at least one. */ + allowedOrigins?: RegExp[]; + /** When true, chunk must declare an SRI `integrity` hash. */ + requiredIntegrity?: boolean; + /** Maximum size in KB the chunk is allowed to occupy. */ + maxSize?: number; + /** Minimum trust level the chunk must declare. */ + trustLevel: TrustLevel; +} + +/** Security metadata attached to a registered chunk. */ +export interface ChunkSecurityMetadata { + origin?: string; + integrity?: string; + trustLevel: TrustLevel; +} + +const DEFAULT_POLICY: ChunkSecurityPolicy = { + requiredIntegrity: true, + maxSize: 500, // KB + trustLevel: 'verified', +}; + +/** + * Ranks trust levels so we can compare "at least" requirements. + * Lower number = higher privilege. + */ +const TRUST_RANK: Record = { + trusted: 0, + verified: 1, + untrusted: 2, +}; + +/** + * Holds the effective security policy for every registered chunk and + * exposes an `audit()` helper used by the bundle optimizer's health report. + */ +export class BundleSecurityContext { + private policies: Map = new Map(); + private metadata: Map = new Map(); + private defaultPolicy: ChunkSecurityPolicy = { ...DEFAULT_POLICY }; + + setDefaultPolicy(policy: Partial): void { + this.defaultPolicy = { ...DEFAULT_POLICY, ...policy }; + } + + getDefaultPolicy(): ChunkSecurityPolicy { + return { ...this.defaultPolicy }; + } + + setPolicyFor(chunkId: string, policy: ChunkSecurityPolicy): void { + this.policies.set(chunkId, policy); + } + + getPolicyFor(chunkId: string): ChunkSecurityPolicy | undefined { + const p = this.policies.get(chunkId); + return p ? { ...p } : undefined; + } + + attachMetadata(chunkId: string, metadata: ChunkSecurityMetadata): void { + this.metadata.set(chunkId, metadata); + } + + /** Removes metadata for a chunk (called when a chunk is unregistered). */ + detachMetadata(chunkId: string): void { + this.metadata.delete(chunkId); + } + + getMetadata(chunkId: string): ChunkSecurityMetadata | undefined { + const m = this.metadata.get(chunkId); + return m ? { ...m } : undefined; + } + + /** + * Resolves the effective policy for a chunk (per-chunk override or default). + * Default fields act as a fallback; per-chunk fields take precedence. + */ + getEffectivePolicy(chunkId: string): ChunkSecurityPolicy { + const per = this.policies.get(chunkId); + return per ? { ...this.defaultPolicy, ...per } : { ...this.defaultPolicy }; + } + + /** + * Audits a single chunk against its effective policy. + * Returns a list of human-readable violations (empty = compliant). + */ + audit(chunk: BundleChunk): string[] { + const policy = this.getEffectivePolicy(chunk.id); + const meta = this.metadata.get(chunk.id) ?? { trustLevel: 'untrusted' as const }; + const violations: string[] = []; + + if (policy.requiredIntegrity) { + const integrity = meta.integrity?.trim() ?? ''; + if (integrity.length === 0) { + violations.push(`Chunk "${chunk.name}" is missing SRI integrity attribute.`); + } + } + + 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; + } + + /** + * Audits every provided chunk. Returns the chunks that have at least one + * violation, indexed by chunk id along with their violation messages. + */ + auditBatch(chunks: BundleChunk[]): Map { + const results = new Map(); + for (const chunk of chunks) { + const v = this.audit(chunk); + if (v.length > 0) results.set(chunk.id, v); + } + return results; + } +} + +/** Shared singleton used by the bundle optimizer and webpack hooks. */ +export const bundleSecurityContext = new BundleSecurityContext();