From 6dd6a36411afebf252b423987903a88b509f071d Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Fri, 28 Aug 2026 23:58:38 +0200 Subject: [PATCH 01/35] test(cockpit): enforce outbound network purity --- tests/cockpit-host/purity.test.ts | 482 ++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index cd5fbe9..368bb6c 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -774,6 +774,305 @@ const acquiresHiddenBuiltin = (source: string): boolean => { return found; }; +/** + * NET — reject outbound network egress at its use/acquisition site + * (D3-CX-POLICY-NET). + * + * The D3 contract forbids network egress ("no network egress"; "not a + * collector"; Stage A is "not live"). The import allowlist already blocks + * `node:https` / `node:net` / `node:tls` (they are not on the exact + * `{node:http, node:url}` allowlist — D3-CX-POLICY-3), but two egress routes + * survive every other existing check: + * - the global `fetch` — a global, so it needs no import and the specifier + * allowlist never sees it; + * - `request()` / `get()` reached through the legitimately-allowed `node:http` + * import — the very module the host needs for `http.createServer`. + * + * This detector closes exactly those two, decided by LEXICAL BINDING IDENTITY — + * the binding visible at each occurrence, never mere identifier text — so + * shadowing neither hides a real capability nor false-positives a same-named + * local (NET-S1/NET-S2): + * - global `fetch`: a `.fetch` member (globalThis/window/self/global + * receiver, incl. a statically-resolved `['fetch']`), a destructuring + * of `fetch` OFF a global receiver, or a bare `fetch` reference that is FREE + * at that occurrence (no lexical binding named `fetch` is visible). An alias + * `const f = fetch` is caught at the `fetch` reference; forwarding a global + * receiver (`const g = globalThis`) is already rejected by RC (e). + * - node:http client APIs: `request` / `get` reached through a binding THIS + * module imported from `node:http` — a default or namespace binding + * (`http.request`, `h.get`), a named import used bare (`request()`), an + * aliased named import (`req()` from `{ request as req }`), or an + * import-equals binding — where the receiver/name still LEXICALLY resolves to + * that import. `createServer` is never in the rejected member set, so the real + * host server is preserved, and `map.get(...)` / `obj.request(...)` on any + * non-node:http receiver is untouched. + * + * Binding identity is resolved by a bounded lexical ENVIRONMENT STACK tied to the + * AST walk: each scope (module, function/arrow/method params, block, for-header, + * catch) pushes a frame naming its own declarations; an occurrence resolves to the + * nearest enclosing frame that binds the name. A module-declared `fetch` + * (`function fetch(){}`, `const fetch = …`) is legal (unlike `eval`) and shadows + * the global where it is visible; a sibling scope's local `fetch` does not, and a + * shadow disappears once its scope closes — while `globalThis.fetch` stays a + * member call. Structural and finite: one parse, one traversal with balanced + * push/pop and Set/Map lookups (no fixpoint, no re-scan, no value resolution), + * reusing `memberNameOf` / `isGlobalReceiver` / `isValueReference` / `unwrapExpr` / + * `bindingPropertyName`. This is a development-time SOURCE-POLICY guard, not a + * runtime sandbox. NOT decided (bounded gaps, not sandbox claims): alias-via- + * assignment (`const h = http; h.request()`), method extraction (`const r = + * http.request`), runtime reassignment, computed/dynamic forwarding, and runtime- + * generated code (RC/HA cover codegen). `node:https`/`net`/`tls` stay out of scope + * — the import allowlist already blocks them. + */ +const HTTP_CLIENT_MEMBERS: ReadonlySet = new Set(['request', 'get']); +const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch']); + +interface HttpBindings { + // Local names bound to the node:http MODULE (`import http` / `import * as h` / + // `import http = require('node:http')`), used as `binding.request(...)`. + readonly namespaceOrDefault: ReadonlySet; + // Local names bound to a node:http CLIENT MEMBER (`import { request, get as g }`), + // used bare as `request(...)` / `g(...)`. A named `createServer` is deliberately + // NOT collected — it is legitimate. + readonly namedClient: ReadonlySet; +} + +// Discover, structurally, the local bindings this module introduces from +// `node:http`. Keyed on the exact specifier `node:http`, so a plain local object +// named `http` (no such import) yields no binding and is never treated as the +// network module. +const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { + const NODE_HTTP = 'node:http'; + const namespaceOrDefault = new Set(); + const namedClient = new Set(); + const visit = (node: ts.Node): void => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === NODE_HTTP && + node.importClause !== undefined + ) { + const clause = node.importClause; + // `import http from 'node:http'` — default binding. + if (clause.name !== undefined) namespaceOrDefault.add(clause.name.text); + const bindings = clause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + // `import * as http from 'node:http'`. + namespaceOrDefault.add(bindings.name.text); + } else { + // `import { request, get as g } from 'node:http'` — collect only the + // client members, under their LOCAL name (`el.name`). + for (const el of bindings.elements) { + const imported = (el.propertyName ?? el.name).text; + if (HTTP_CLIENT_MEMBERS.has(imported)) namedClient.add(el.name.text); + } + } + } + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + ts.isStringLiteral(node.moduleReference.expression) && + node.moduleReference.expression.text === NODE_HTTP + ) { + // `import http = require('node:http')`. + namespaceOrDefault.add(node.name.text); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return { namespaceOrDefault, namedClient }; +}; + +// A lexical binding kind for the identity-sensitive detection below. `HTTP_NS` and +// `HTTP_CLIENT` mark the module-level node:http import bindings (namespace/default, +// and named `request`/`get` respectively); `LOCAL` marks any OTHER declaration +// (parameter, const/let/var, function/class, catch, or unrelated import) that +// SHADOWS an outer binding of the same name. Capability identity is therefore the +// binding VISIBLE at an occurrence, never mere identifier text (NET-S1/NET-S2). +type BindingKind = 'HTTP_NS' | 'HTTP_CLIENT' | 'LOCAL'; + +// Collect every identifier a binding name introduces — a plain identifier or a +// (possibly nested) destructuring pattern — into `sink`. Bounded by the finite +// binding-pattern tree; performs no value resolution. +const eachBoundName = (name: ts.BindingName, sink: (text: string) => void): void => { + if (ts.isIdentifier(name)) { + sink(name.text); + return; + } + for (const element of name.elements) { + if (ts.isBindingElement(element)) eachBoundName(element.name, sink); + } +}; + +// The names a single statement declares DIRECTLY in its own scope — const/let/var +// declarations and function/class declaration names — with no descent into nested +// blocks or functions (those get their own frames). The required matrices exercise +// only const/let/params, so `var`'s function-hoisting is a stated bounded gap that +// cannot make a covered case wrong. +const declaredByStatement = (statement: ts.Statement, sink: (text: string) => void): void => { + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) eachBoundName(decl.name, sink); + } else if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + statement.name !== undefined + ) { + sink(statement.name.text); + } +}; + +// The module (top-level) lexical frame: node:http import bindings tagged with their +// capability kind, every other top-level binding tagged LOCAL. This is the outermost +// frame for the whole file, so a genuine module-level `const fetch` shadows the +// global `fetch` module-wide (NET-S2), while `globalThis.fetch` stays a member call. +const buildModuleFrame = (sourceFile: ts.SourceFile, http: HttpBindings): Map => { + const frame = new Map(); + for (const statement of sourceFile.statements) { + declaredByStatement(statement, (text) => frame.set(text, 'LOCAL')); + if (ts.isImportDeclaration(statement) && statement.importClause !== undefined) { + const clause = statement.importClause; + if (clause.name !== undefined) frame.set(clause.name.text, 'LOCAL'); + const bindings = clause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + frame.set(bindings.name.text, 'LOCAL'); + } else { + for (const el of bindings.elements) frame.set(el.name.text, 'LOCAL'); + } + } + } else if (ts.isImportEqualsDeclaration(statement)) { + frame.set(statement.name.text, 'LOCAL'); + } + } + // Capability override: the node:http import bindings win their specific kind over + // the generic LOCAL tag applied above. + for (const name of http.namespaceOrDefault) frame.set(name, 'HTTP_NS'); + for (const name of http.namedClient) frame.set(name, 'HTTP_CLIENT'); + return frame; +}; + +// Whether an object-binding element destructures OFF a global receiver, i.e. +// `const { fetch: f } = globalThis`. Restricts the destructuring rule to the real +// global so `const { fetch } = someLocalConfig` is not a false positive. +const destructuresGlobalReceiver = (el: ts.BindingElement): boolean => { + const decl = el.parent.parent; + return ts.isVariableDeclaration(decl) && decl.initializer !== undefined && isGlobalReceiver(decl.initializer); +}; + +const usesOutboundNetwork = (source: string): boolean => { + const sourceFile = ts.createSourceFile( + 'module.ts', + source, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ts.ScriptKind.TS, + ); + const constMap = collectStringConsts(sourceFile); + const http = collectHttpBindings(sourceFile); + + // Lexical environment: a stack of frames, innermost last. `resolve` returns the + // kind of the NEAREST visible binding of a name, or undefined when the name is + // FREE at that occurrence (an unshadowed global such as `fetch`). Frames are + // pushed on scope entry and popped on scope exit, so sibling scopes are + // independent and an inner shadow vanishes once its scope closes. + const scopes: Map[] = [buildModuleFrame(sourceFile, http)]; + const resolve = (name: string): BindingKind | undefined => { + for (let i = scopes.length - 1; i >= 0; i--) { + const kind = scopes[i]?.get(name); + if (kind !== undefined) return kind; + } + return undefined; + }; + + // The frame a scope-introducing node contributes, or null when it is not one. + // Function-likes contribute their parameters; blocks their direct lexical + // declarations; for-headers their loop variables; catch clauses their variable. + const frameFor = (node: ts.Node): Map | null => { + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) { + const frame = new Map(); + for (const param of node.parameters) eachBoundName(param.name, (t) => frame.set(t, 'LOCAL')); + return frame; + } + if (ts.isBlock(node) || ts.isModuleBlock(node)) { + const frame = new Map(); + for (const statement of node.statements) declaredByStatement(statement, (t) => frame.set(t, 'LOCAL')); + return frame; + } + if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) { + const frame = new Map(); + const init = node.initializer; + if (init !== undefined && ts.isVariableDeclarationList(init)) { + for (const decl of init.declarations) eachBoundName(decl.name, (t) => frame.set(t, 'LOCAL')); + } + return frame; + } + if (ts.isCatchClause(node)) { + const frame = new Map(); + if (node.variableDeclaration !== undefined) { + eachBoundName(node.variableDeclaration.name, (t) => frame.set(t, 'LOCAL')); + } + return frame; + } + return null; + }; + + let found = false; + const visit = (node: ts.Node): void => { + const frame = frameFor(node); + if (frame !== null) scopes.push(frame); + + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const member = memberNameOf(node, constMap); + if (member !== null) { + // (a) `.fetch` / statically-keyed `['fetch']` — a global + // RECEIVER, independent of any `fetch` binding. + if (NETWORK_GLOBAL_NAMES.has(member) && isGlobalReceiver(node.expression)) found = true; + // (d) `.request` / `.get` only when the receiver identifier + // LEXICALLY resolves to the module's node:http namespace/default import + // (not a shadowing local). `createServer` is never a client member. + const recv = unwrapExpr(node.expression); + if (HTTP_CLIENT_MEMBERS.has(member) && ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') { + found = true; + } + } + } + // (b) destructuring `fetch` off a global receiver. + if (ts.isBindingElement(node)) { + const name = bindingPropertyName(node); + if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && destructuresGlobalReceiver(node)) found = true; + } + if (ts.isIdentifier(node) && isValueReference(node)) { + // The `key` in a destructuring `const { key: local } = …` is a binding + // PROPERTY name, not an expression read (handled receiver-scoped by rule (b)), + // so it must not be mistaken for a bare reference to the global. + const isBindingPropertyKey = ts.isBindingElement(node.parent) && node.parent.propertyName === node; + if (!isBindingPropertyKey) { + const kind = resolve(node.text); + // (c) a bare `fetch` reference that is FREE here — no lexical binding named + // `fetch` is visible — so it is the global. A local shadow resolves to + // LOCAL and is allowed; `const f = fetch` is still caught at `fetch`. + if (NETWORK_GLOBAL_NAMES.has(node.text) && kind === undefined) found = true; + // (e) a bare reference that lexically resolves to a node:http named client + // import (`request(...)`, `req(...)`); a shadowing local resolves LOCAL. + if (kind === 'HTTP_CLIENT') found = true; + } + } + + ts.forEachChild(node, visit); + if (frame !== null) scopes.pop(); + }; + ts.forEachChild(sourceFile, visit); + return found; +}; + describe('D3 host has no mutation, subprocess, secret, or Git capability', () => { it('references no subprocess, environment, or Git operation', () => { const forbidden: readonly RegExp[] = [ @@ -3361,3 +3660,186 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI expect(hostSources().length).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// NET — the host must perform no outbound network egress (D3-CX-POLICY-NET). +// The two routes that survive every OTHER purity check are the global `fetch` +// (needs no import) and `request`/`get` reached through the already-allowed +// `node:http` module. `usesOutboundNetwork` closes exactly those, receiver-scoped, +// while preserving `http.createServer` and every unrelated `.get`/`.request`. +// node:https/net/tls stay covered by the import allowlist (D3-CX-POLICY-3), not +// here — see the detector's doc comment. +// --------------------------------------------------------------------------- +describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { + it('accepts every real host source (no egress is present today)', () => { + for (const { file, text } of hostSources()) { + expect(usesOutboundNetwork(text), `${file} performs outbound network egress`).toBe(false); + } + }); + + // --- MUST REJECT: global fetch, at every bounded acquisition/use site --- + const rejectFetch: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a bare global fetch call', source: `export async function p() { await fetch('https://example.com/'); }` }, + { form: 'globalThis.fetch', source: `globalThis.fetch('https://example.com/');` }, + { form: 'a statically-keyed globalThis["fetch"]', source: `globalThis['fetch']('https://example.com/');` }, + { form: 'a window.fetch receiver', source: `window.fetch('https://example.com/');` }, + { form: 'a self.fetch receiver', source: `self.fetch('https://example.com/');` }, + { form: 'an aliased global fetch acquisition (const f = fetch)', source: `const f = fetch;\nf('https://example.com/');` }, + { + form: 'a destructured global fetch acquisition', + source: `const { fetch: f } = globalThis;\nf('https://example.com/');`, + }, + ]; + for (const { form, source } of rejectFetch) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- MUST REJECT: node:http client APIs, bound specifically to node:http --- + const rejectHttp: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a default node:http binding .request', source: `import http from 'node:http';\nhttp.request('http://example.com/');` }, + { form: 'a default node:http binding .get', source: `import http from 'node:http';\nhttp.get('http://example.com/');` }, + { form: 'a namespace node:http binding .request', source: `import * as http from 'node:http';\nhttp.request('http://example.com/');` }, + { form: 'a namespace-ALIAS binding .request', source: `import * as h from 'node:http';\nh.request('http://example.com/');` }, + { form: 'a namespace-ALIAS binding .get', source: `import * as h from 'node:http';\nh.get('http://example.com/');` }, + { form: 'a statically-keyed member on a node:http binding', source: `import * as http from 'node:http';\nhttp['request']('http://example.com/');` }, + { form: 'a named request import used bare', source: `import { request } from 'node:http';\nrequest('http://example.com/');` }, + { form: 'a named get import used bare', source: `import { get } from 'node:http';\nget('http://example.com/');` }, + { form: 'an aliased named request import', source: `import { request as req } from 'node:http';\nreq('http://example.com/');` }, + { form: 'an aliased named get import', source: `import { get as httpGet } from 'node:http';\nhttpGet('http://example.com/');` }, + { form: 'an import-equals node:http binding .request', source: `import http = require('node:http');\nhttp.request('http://example.com/');` }, + ]; + for (const { form, source } of rejectHttp) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- MUST ALLOW: createServer and every unrelated member/name --- + const allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'http.createServer on a default binding', source: `import http from 'node:http';\nhttp.createServer(() => {});` }, + { form: 'createServer on a namespace alias', source: `import * as h from 'node:http';\nh.createServer(() => {});` }, + { form: 'a named createServer import used bare', source: `import { createServer } from 'node:http';\ncreateServer(() => {});` }, + { form: 'Map.get / map.get', source: `const m = new Map();\nvoid m.get('k');` }, + { + form: '.get / .request on an unrelated local object', + source: `const api = { get(x: string) { return x; }, request(x: string) { return x; } };\nvoid api.get('a');\nvoid api.request('b');`, + }, + { + form: '.fetch on an ordinary local object (non-global receiver)', + source: `const store = { fetch(x: string) { return x; } };\nvoid store.fetch('a');`, + }, + { + form: 'a member named request on a plain local object also named http', + source: `const http = { request(x: string) { return x; } };\nvoid http.request('a');`, + }, + { form: 'a local non-network function named fetch', source: `function fetch(x: string) { return x; }\nvoid fetch('a');` }, + { form: 'a local non-network const named request', source: `const request = (x: string) => x;\nvoid request('a');` }, + { form: 'a local non-network const named get', source: `const get = (x: string) => x;\nvoid get('a');` }, + { + form: 'the real server.ts createServer shape (handler param named request)', + source: + `import http from 'node:http';\n` + + `export function make() {\n` + + ` return http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {\n` + + ` const method = request.method ?? '';\n` + + ` void method;\n` + + ` void response;\n` + + ` });\n` + + `}`, + }, + ]; + for (const { form, source } of allow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // --- FALSE-POSITIVE attack: strings / comments / regex must not fabricate egress --- + it('does not fire on egress-looking text inside strings, comments, regex, or templates', () => { + expect(usesOutboundNetwork(`const s = "fetch('https://x/')";\nvoid s;`)).toBe(false); + expect(usesOutboundNetwork(`// fetch('https://x/') and http.request('http://x/')\nexport const ok = true;`)).toBe(false); + expect(usesOutboundNetwork(`/* http.get('http://x/') */\nexport const ok = true;`)).toBe(false); + expect(usesOutboundNetwork(`const re = /fetch\\(/;\nvoid re;`)).toBe(false); + expect(usesOutboundNetwork(`const t = \`http.request('\${'http://x/'}')\`;\nvoid t;`)).toBe(false); + }); + + it('does not treat destructuring `fetch` off a local (non-global) object as egress', () => { + expect(usesOutboundNetwork(`const cfg = { fetch: (x: string) => x };\nconst { fetch: f } = cfg;\nvoid f('a');`)).toBe(false); + }); + + // --- FALSE-NEGATIVE attack: the rule must not depend on the literal name `http` --- + it('still fires when the node:http namespace binding is renamed', () => { + expect(usesOutboundNetwork(`import * as anyName from 'node:http';\nanyName.get('http://example.com/');`)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// NET lexical-binding identity (NET-S1 / NET-S2). Capability identity is the +// binding VISIBLE at an occurrence, not identifier text: a same-named local +// (const/let/var/param/catch/destructuring, in any nested or sibling scope) shadows +// the node:http import or the global `fetch` only inside its own scope, and the +// outer binding is restored on scope exit. `usesOutboundNetwork` decides this with a +// bounded lexical environment stack over one AST traversal — see the detector's doc. +// --------------------------------------------------------------------------- +describe('D3 host network egress uses lexical binding identity, not name text (D3-CX-POLICY-NET-SHADOW)', () => { + // NET-S1 — a local binding that shadows the node:http import is ALLOWED. + const s1Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a namespace import shadowed by a function-local const', source: `import * as http from 'node:http';\nfunction f() {\n const http = { request(v: string) { return v; } };\n return http.request('local');\n}` }, + { form: 'a namespace import shadowed by a parameter', source: `import * as http from 'node:http';\nfunction f(http: { request(v: string): string }) {\n return http.request('local');\n}` }, + { form: 'a named request import shadowed by a function-local const', source: `import { request } from 'node:http';\nfunction f() {\n const request = (x: string) => x;\n return request('local');\n}` }, + { form: 'a named request import shadowed by a parameter', source: `import { request } from 'node:http';\nfunction f(request: (x: string) => string) {\n return request('local');\n}` }, + { form: 'a named get import shadowed by a parameter', source: `import { get } from 'node:http';\nfunction f(get: (x: string) => string) {\n return get('local');\n}` }, + { form: 'a namespace import shadowed in a nested block, used inside that block', source: `import * as http from 'node:http';\nfunction f() {\n {\n const http = { request(v: string) { return v; } };\n http.request('local');\n }\n}` }, + { form: 'a named import shadowed by a catch binding', source: `import { get } from 'node:http';\nfunction f() {\n try {\n /* work */\n } catch (get) {\n (get as (x: string) => string)('local');\n }\n}` }, + { form: 'a named import shadowed by a destructuring parameter', source: `import { request } from 'node:http';\nfunction f({ request }: { request: (x: string) => string }) {\n return request('local');\n}` }, + { form: 'only the inner shadowed use (outer binding never called)', source: `import * as http from 'node:http';\nfunction local() {\n const http = { request(v: string) { return v; } };\n return http.request('local');\n}` }, + ]; + for (const { form, source } of s1Allow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // NET-S1 — the imported capability is still REJECTED where it is actually visible. + const s1Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the imported namespace binding after a sibling function shadows the name', source: `import * as http from 'node:http';\nfunction local() {\n const http = { request(v: string) { return v; } };\n http.request('local');\n}\nhttp.request('https://evil/');` }, + { form: 'the imported binding after a nested block shadows then closes', source: `import * as http from 'node:http';\nfunction f() {\n {\n const http = { request(v: string) { return v; } };\n http.request('local');\n }\n http.request('https://evil/');\n}` }, + { form: 'a sibling function that does NOT shadow the named import', source: `import { get } from 'node:http';\nfunction outer() {\n function inner() {\n return get('https://evil/');\n }\n return inner;\n}` }, + ]; + for (const { form, source } of s1Reject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // NET-S2 — a local `fetch` shadows the global only inside its own scope. + it('ALLOWS a function-local fetch used only within that function', () => { + expect(usesOutboundNetwork(`function helper() {\n const fetch = (x: string) => x;\n return fetch('local');\n}`)).toBe(false); + }); + it('ALLOWS a parameter-shadowed fetch used only within that function', () => { + expect(usesOutboundNetwork(`function helper(fetch: (x: string) => string) {\n return fetch('local');\n}`)).toBe(false); + }); + it('ALLOWS a genuine module-level local fetch used bare across the module', () => { + expect(usesOutboundNetwork(`const fetch = (x: string) => x;\nfetch('local');`)).toBe(false); + }); + it('REJECTS a bare global fetch in a sibling function, despite a local fetch elsewhere', () => { + expect( + usesOutboundNetwork( + `function helper() {\n const fetch = (x: string) => x;\n return fetch('local');\n}\nexport function leak() {\n return fetch('https://evil.example/');\n}`, + ), + ).toBe(true); + }); + it('REJECTS a bare global fetch after a parameter-shadowed scope closes', () => { + expect(usesOutboundNetwork(`function helper(fetch: (x: string) => string) {\n return fetch('local');\n}\nfetch('https://evil/');`)).toBe(true); + }); + it('REJECTS globalThis.fetch even when a module-level local fetch exists', () => { + expect(usesOutboundNetwork(`const fetch = (x: string) => x;\nvoid fetch('local');\nglobalThis.fetch('https://evil/');`)).toBe(true); + }); + + // Sibling scopes with the same binding name do not contaminate one another. + it('keeps sibling function scopes independent (both shadow, both allowed)', () => { + expect(usesOutboundNetwork(`import * as http from 'node:http';\nfunction a(http: { request(v: string): string }) {\n return http.request('a');\n}\nfunction b(http: { get(v: string): string }) {\n return http.get('b');\n}`)).toBe(false); + }); +}); From ba636b23ae2b9de07cca5d41b247a8db5eb8b5fc Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 01:09:19 +0200 Subject: [PATCH 02/35] test(cockpit): close network egress review gaps --- tests/cockpit-host/purity.test.ts | 187 +++++++++++++++++++++++------- 1 file changed, 142 insertions(+), 45 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 368bb6c..4a9dabf 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -785,55 +785,63 @@ const acquiresHiddenBuiltin = (source: string): boolean => { * survive every other existing check: * - the global `fetch` — a global, so it needs no import and the specifier * allowlist never sees it; - * - `request()` / `get()` reached through the legitimately-allowed `node:http` - * import — the very module the host needs for `http.createServer`. + * - `request()` / `get()` / `new ClientRequest()` reached through the + * legitimately-allowed `node:http` import — the very module the host needs for + * `http.createServer`. * * This detector closes exactly those two, decided by LEXICAL BINDING IDENTITY — * the binding visible at each occurrence, never mere identifier text — so * shadowing neither hides a real capability nor false-positives a same-named * local (NET-S1/NET-S2): - * - global `fetch`: a `.fetch` member (globalThis/window/self/global - * receiver, incl. a statically-resolved `['fetch']`), a destructuring - * of `fetch` OFF a global receiver, or a bare `fetch` reference that is FREE - * at that occurrence (no lexical binding named `fetch` is visible). An alias - * `const f = fetch` is caught at the `fetch` reference; forwarding a global - * receiver (`const g = globalThis`) is already rejected by RC (e). - * - node:http client APIs: `request` / `get` reached through a binding THIS - * module imported from `node:http` — a default or namespace binding - * (`http.request`, `h.get`), a named import used bare (`request()`), an - * aliased named import (`req()` from `{ request as req }`), or an - * import-equals binding — where the receiver/name still LEXICALLY resolves to - * that import. `createServer` is never in the rejected member set, so the real - * host server is preserved, and `map.get(...)` / `obj.request(...)` on any - * non-node:http receiver is untouched. + * - global `fetch`: a `.fetch` member on a global receiver name + * (globalThis/window/self/global, incl. a statically-resolved `['fetch']`) + * that is itself FREE here — a lexically-shadowing local receiver + * (`function f(globalThis){ globalThis.fetch(...) }`) is an ordinary object and is + * allowed; a destructuring of `fetch` OFF such a free global receiver; or a bare + * `fetch` reference that is FREE at that occurrence (no lexical binding named + * `fetch` is visible). An alias `const f = fetch` is caught at the `fetch` + * reference; forwarding a global receiver (`const g = globalThis`) is already + * rejected by RC (e). + * - node:http client APIs: `request` / `get` / `ClientRequest` reached through a + * binding THIS module imported from `node:http` — a default or namespace binding + * (`http.request`, `h.get`, `new http.ClientRequest()`), a named import used bare + * (`request()`, `new ClientRequest()`), an aliased named import (`req()` from + * `{ request as req }`), or an import-equals binding — where the receiver/name + * still LEXICALLY resolves to that import. `createServer` is never in the rejected + * member set, so the real host server is preserved, and `map.get(...)` / + * `obj.request(...)` / an unrelated local `new ClientRequest()` on any + * non-node:http binding is untouched. * * Binding identity is resolved by a bounded lexical ENVIRONMENT STACK tied to the * AST walk: each scope (module, function/arrow/method params, block, for-header, * catch) pushes a frame naming its own declarations; an occurrence resolves to the - * nearest enclosing frame that binds the name. A module-declared `fetch` - * (`function fetch(){}`, `const fetch = …`) is legal (unlike `eval`) and shadows - * the global where it is visible; a sibling scope's local `fetch` does not, and a - * shadow disappears once its scope closes — while `globalThis.fetch` stays a - * member call. Structural and finite: one parse, one traversal with balanced - * push/pop and Set/Map lookups (no fixpoint, no re-scan, no value resolution), - * reusing `memberNameOf` / `isGlobalReceiver` / `isValueReference` / `unwrapExpr` / - * `bindingPropertyName`. This is a development-time SOURCE-POLICY guard, not a + * nearest enclosing frame that binds the name. A NAMED function expression also + * binds its own name inside its body (so `const helper = function request(){ return + * request(); }` resolves the inner call to that self-binding, not the import). A + * module-declared `fetch` (`function fetch(){}`, `const fetch = …`) is legal (unlike + * `eval`) and shadows the global where it is visible; a sibling scope's local `fetch` + * does not, and a shadow disappears once its scope closes — while a FREE-receiver + * `globalThis.fetch` stays a member call. Structural and finite: one parse, one + * traversal with balanced push/pop and Set/Map lookups (no fixpoint, no re-scan, no + * value resolution), reusing `memberNameOf` / `GLOBAL_RECEIVER_NAMES` (resolved + * lexically here) / `isValueReference` / `unwrapExpr` / `bindingPropertyName`. This is + * a development-time SOURCE-POLICY guard, not a * runtime sandbox. NOT decided (bounded gaps, not sandbox claims): alias-via- * assignment (`const h = http; h.request()`), method extraction (`const r = * http.request`), runtime reassignment, computed/dynamic forwarding, and runtime- * generated code (RC/HA cover codegen). `node:https`/`net`/`tls` stay out of scope * — the import allowlist already blocks them. */ -const HTTP_CLIENT_MEMBERS: ReadonlySet = new Set(['request', 'get']); +const HTTP_CLIENT_MEMBERS: ReadonlySet = new Set(['request', 'get', 'ClientRequest']); const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch']); interface HttpBindings { // Local names bound to the node:http MODULE (`import http` / `import * as h` / // `import http = require('node:http')`), used as `binding.request(...)`. readonly namespaceOrDefault: ReadonlySet; - // Local names bound to a node:http CLIENT MEMBER (`import { request, get as g }`), - // used bare as `request(...)` / `g(...)`. A named `createServer` is deliberately - // NOT collected — it is legitimate. + // Local names bound to a node:http CLIENT capability (`import { request, get as g, + // ClientRequest }`), used bare as `request(...)` / `g(...)` / `new ClientRequest()`. + // A named `createServer` is deliberately NOT collected — it is legitimate. readonly namedClient: ReadonlySet; } @@ -886,7 +894,8 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { // A lexical binding kind for the identity-sensitive detection below. `HTTP_NS` and // `HTTP_CLIENT` mark the module-level node:http import bindings (namespace/default, -// and named `request`/`get` respectively); `LOCAL` marks any OTHER declaration +// and named `request`/`get`/`ClientRequest` respectively); `LOCAL` marks any OTHER +// declaration // (parameter, const/let/var, function/class, catch, or unrelated import) that // SHADOWS an outer binding of the same name. Capability identity is therefore the // binding VISIBLE at an occurrence, never mere identifier text (NET-S1/NET-S2). @@ -951,14 +960,6 @@ const buildModuleFrame = (sourceFile: ts.SourceFile, http: HttpBindings): Map { - const decl = el.parent.parent; - return ts.isVariableDeclaration(decl) && decl.initializer !== undefined && isGlobalReceiver(decl.initializer); -}; - const usesOutboundNetwork = (source: string): boolean => { const sourceFile = ts.createSourceFile( 'module.ts', @@ -984,6 +985,18 @@ const usesOutboundNetwork = (source: string): boolean => { return undefined; }; + // A receiver is the REAL global object only when its identifier is a global name + // (globalThis/window/self/global) AND no lexical binding shadows it at this + // occurrence — identifier text is not binding identity. So + // `function f(globalThis) { globalThis.fetch('local'); }` resolves the receiver to + // the LOCAL parameter and is NOT the global, while an unshadowed `globalThis.fetch` + // (FREE receiver) stays a real global member. Uses the same scope stack as `resolve` + // rather than the RC-shared text-only `isGlobalReceiver`, so this stays local to NET. + const isLexicalGlobalReceiver = (expr: ts.Expression): boolean => { + const recv = unwrapExpr(expr); + return ts.isIdentifier(recv) && GLOBAL_RECEIVER_NAMES.has(recv.text) && resolve(recv.text) === undefined; + }; + // The frame a scope-introducing node contributes, or null when it is not one. // Function-likes contribute their parameters; blocks their direct lexical // declarations; for-headers their loop variables; catch clauses their variable. @@ -999,6 +1012,16 @@ const usesOutboundNetwork = (source: string): boolean => { ) { const frame = new Map(); for (const param of node.parameters) eachBoundName(param.name, (t) => frame.set(t, 'LOCAL')); + // A NAMED function EXPRESSION binds its own name inside its body only (unlike a + // function DECLARATION, whose name lives in the enclosing scope and is already + // recorded there by `declaredByStatement`). Recording the self-binding in this + // frame lets `const helper = function request() { return request(); }` resolve + // the inner recursive call to the LOCAL self-binding, shadowing an imported + // `request`; the frame is popped on scope exit, so it never leaks to siblings or + // to the enclosing scope, where the import must still be rejected. + if (ts.isFunctionExpression(node) && node.name !== undefined) { + frame.set(node.name.text, 'LOCAL'); + } return frame; } if (ts.isBlock(node) || ts.isModuleBlock(node)) { @@ -1032,22 +1055,32 @@ const usesOutboundNetwork = (source: string): boolean => { if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const member = memberNameOf(node, constMap); if (member !== null) { - // (a) `.fetch` / statically-keyed `['fetch']` — a global - // RECEIVER, independent of any `fetch` binding. - if (NETWORK_GLOBAL_NAMES.has(member) && isGlobalReceiver(node.expression)) found = true; - // (d) `.request` / `.get` only when the receiver identifier - // LEXICALLY resolves to the module's node:http namespace/default import - // (not a shadowing local). `createServer` is never a client member. + // (a) `.fetch` / statically-keyed `['fetch']` — only when the + // receiver identifier is a global name that is FREE here (no local binding + // shadows it). A shadowing local (param/const/…) makes it an ordinary + // object, so `function f(globalThis) { globalThis.fetch(...) }` is allowed. + if (NETWORK_GLOBAL_NAMES.has(member) && isLexicalGlobalReceiver(node.expression)) found = true; + // (d) `.request` / `.get` / `.ClientRequest` only when the receiver + // identifier LEXICALLY resolves to the module's node:http namespace/default + // import (not a shadowing local). `createServer` is never a client member. const recv = unwrapExpr(node.expression); if (HTTP_CLIENT_MEMBERS.has(member) && ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') { found = true; } } } - // (b) destructuring `fetch` off a global receiver. + // (b) destructuring `fetch` off a global receiver, i.e. `const { fetch: f } = + // globalThis` — but only when that receiver is the FREE global (a shadowing + // local `globalThis`/… makes it an ordinary object, and `const { fetch } = + // someLocalConfig` is untouched). if (ts.isBindingElement(node)) { const name = bindingPropertyName(node); - if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && destructuresGlobalReceiver(node)) found = true; + const decl = node.parent.parent; + const offGlobalReceiver = + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + isLexicalGlobalReceiver(decl.initializer); + if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && offGlobalReceiver) found = true; } if (ts.isIdentifier(node) && isValueReference(node)) { // The `key` in a destructuring `const { key: local } = …` is a binding @@ -3709,6 +3742,12 @@ describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { { form: 'an aliased named request import', source: `import { request as req } from 'node:http';\nreq('http://example.com/');` }, { form: 'an aliased named get import', source: `import { get as httpGet } from 'node:http';\nhttpGet('http://example.com/');` }, { form: 'an import-equals node:http binding .request', source: `import http = require('node:http');\nhttp.request('http://example.com/');` }, + { form: 'a named ClientRequest import constructed', source: `import { ClientRequest } from 'node:http';\nnew ClientRequest('http://example.com/').end();` }, + { form: 'an aliased named ClientRequest import constructed', source: `import { ClientRequest as CR } from 'node:http';\nnew CR('http://example.com/').end();` }, + { form: 'a namespace node:http binding new .ClientRequest', source: `import * as http from 'node:http';\nnew http.ClientRequest('http://example.com/').end();` }, + { form: 'a default node:http binding new .ClientRequest', source: `import http from 'node:http';\nnew http.ClientRequest('http://example.com/').end();` }, + { form: 'an import-equals node:http binding new .ClientRequest', source: `import http = require('node:http');\nnew http.ClientRequest('http://example.com/').end();` }, + { form: 'a statically-keyed ClientRequest member on a node:http binding', source: `import * as http from 'node:http';\nnew http['ClientRequest']('http://example.com/').end();` }, ]; for (const { form, source } of rejectHttp) { it(`rejects ${form}`, () => { @@ -3737,6 +3776,9 @@ describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { { form: 'a local non-network function named fetch', source: `function fetch(x: string) { return x; }\nvoid fetch('a');` }, { form: 'a local non-network const named request', source: `const request = (x: string) => x;\nvoid request('a');` }, { form: 'a local non-network const named get', source: `const get = (x: string) => x;\nvoid get('a');` }, + { form: 'an unrelated local class named ClientRequest', source: `class ClientRequest {}\nvoid new ClientRequest();` }, + { form: 'an unrelated local const constructor named ClientRequest', source: `const LocalCtor = class {};\nconst ClientRequest = LocalCtor;\nvoid new ClientRequest();` }, + { form: 'a ClientRequest member on a plain local object (non-node:http)', source: `const LocalCtor = class {};\nconst http = { ClientRequest: LocalCtor };\nvoid new http.ClientRequest();` }, { form: 'the real server.ts createServer shape (handler param named request)', source: @@ -3842,4 +3884,59 @@ describe('D3 host network egress uses lexical binding identity, not name text (D it('keeps sibling function scopes independent (both shadow, both allowed)', () => { expect(usesOutboundNetwork(`import * as http from 'node:http';\nfunction a(http: { request(v: string): string }) {\n return http.request('a');\n}\nfunction b(http: { get(v: string): string }) {\n return http.get('b');\n}`)).toBe(false); }); + + // NET-S3 — a GLOBAL RECEIVER name (globalThis/window/self/global) is the real global + // only when it is FREE at the occurrence; a lexically-shadowing local makes it an + // ordinary object. Identifier text is not binding identity for receivers either. + const receiverAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a parameter-shadowed globalThis receiver', source: `function f(globalThis: { fetch: (v: string) => string }) {\n return globalThis.fetch('local');\n}` }, + { form: 'a parameter-shadowed window receiver', source: `function f(window: { fetch: (v: string) => string }) {\n return window.fetch('local');\n}` }, + { form: 'a parameter-shadowed self receiver', source: `function f(self: { fetch: (v: string) => string }) {\n return self.fetch('local');\n}` }, + { form: 'a parameter-shadowed global receiver', source: `function f(global: { fetch: (v: string) => string }) {\n return global.fetch('local');\n}` }, + { form: 'a block-shadowed globalThis receiver used within that block', source: `function f() {\n {\n const globalThis = { fetch(v: string) { return v; } };\n globalThis.fetch('local');\n }\n}` }, + { form: 'a destructuring of fetch off a shadowed globalThis', source: `function f(globalThis: { fetch: (v: string) => string }) {\n const { fetch: g } = globalThis;\n return g('local');\n}` }, + ]; + for (const { form, source } of receiverAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + const receiverReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a real globalThis.fetch receiver (unshadowed)', source: `globalThis.fetch('https://evil/');` }, + { form: 'a real window.fetch receiver (unshadowed)', source: `window.fetch('https://evil/');` }, + { form: 'a real self.fetch receiver (unshadowed)', source: `self.fetch('https://evil/');` }, + { form: 'a real globalThis.fetch after a shadowing parameter scope closes', source: `function f(globalThis: { fetch: (v: string) => string }) {\n return globalThis.fetch('local');\n}\nglobalThis.fetch('https://evil/');` }, + { form: 'a real globalThis.fetch after a shadowing block closes', source: `function f() {\n {\n const globalThis = { fetch(v: string) { return v; } };\n globalThis.fetch('local');\n }\n globalThis.fetch('https://evil/');\n}` }, + ]; + for (const { form, source } of receiverReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // NET-S4 — a NAMED function EXPRESSION binds its own name inside its body, shadowing + // an outer import there, but never outside the expression. + const fnExprAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a named function-expression request recursion shadowing the import', source: `import { request } from 'node:http';\nconst helper = function request(): unknown {\n return request();\n};\nvoid helper;` }, + { form: 'a named function-expression matching an aliased import name', source: `import { request as req } from 'node:http';\nconst helper = function req(): unknown {\n return req();\n};\nvoid helper;` }, + { form: 'a named function-expression get recursion shadowing the import', source: `import { get } from 'node:http';\nconst helper = function get(): unknown {\n return get();\n};\nvoid helper;` }, + { form: 'a named function-expression fetch recursion shadowing the global', source: `const helper = function fetch(): unknown {\n return fetch();\n};\nvoid helper;` }, + ]; + for (const { form, source } of fnExprAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + const fnExprReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the imported request used OUTSIDE a same-named function expression', source: `import { request } from 'node:http';\nconst helper = function request(): unknown {\n return request();\n};\nvoid helper;\nrequest('https://evil/');` }, + { form: 'the imported request in a SIBLING that does not share the fn-expr name', source: `import { request } from 'node:http';\nconst a = function request(): unknown {\n return request();\n};\nconst b = function other(): unknown {\n return request('https://evil/');\n};\nvoid a;\nvoid b;` }, + { form: 'the global fetch OUTSIDE a same-named function expression', source: `const helper = function fetch(): unknown {\n return fetch();\n};\nvoid helper;\nfetch('https://evil/');` }, + ]; + for (const { form, source } of fnExprReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } }); From c71f3266c7a33fa5052d6ef67c78b465fd48b7e3 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 02:00:00 +0200 Subject: [PATCH 03/35] test(cockpit): enforce finite network capability model --- tests/cockpit-host/purity.test.ts | 185 ++++++++++++++++++++++-------- 1 file changed, 134 insertions(+), 51 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 4a9dabf..8d10e73 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -781,36 +781,46 @@ const acquiresHiddenBuiltin = (source: string): boolean => { * The D3 contract forbids network egress ("no network egress"; "not a * collector"; Stage A is "not live"). The import allowlist already blocks * `node:https` / `node:net` / `node:tls` (they are not on the exact - * `{node:http, node:url}` allowlist — D3-CX-POLICY-3), but two egress routes - * survive every other existing check: - * - the global `fetch` — a global, so it needs no import and the specifier - * allowlist never sees it; - * - `request()` / `get()` / `new ClientRequest()` reached through the - * legitimately-allowed `node:http` import — the very module the host needs for - * `http.createServer`. + * `{node:http, node:url}` allowlist — D3-CX-POLICY-3), and with `node:net`/`tls`/ + * `dgram`/`http2` therefore unreachable, the COMPLETE outbound surface that survives + * every other check is finite and closed by construction: + * - importless network GLOBALS — `fetch` and (on the Node target) `WebSocket` are + * built-in globals, so they need no import and the specifier allowlist never sees + * them. This is the whole importless-network-global family on the target runtime, + * not an open-ended blacklist: a raw socket needs `node:net`/`tls`, which the + * import allowlist blocks. + * - any NON-`createServer` value reached through the legitimately-allowed + * `node:http` import. The read-only host needs exactly ONE node:http VALUE export + * — the inbound-server constructor `createServer`; every other value member/name + * (`request`, `get`, `ClientRequest`, `Agent`, `Agent.createConnection`, …) is a + * non-server (outbound/connection) capability. So node:http is decided by a finite + * POSITIVE model — allow `createServer`, reject the rest — and a newly-noticed + * client API needs NO new special case. * - * This detector closes exactly those two, decided by LEXICAL BINDING IDENTITY — + * This detector closes both families, decided by LEXICAL BINDING IDENTITY — * the binding visible at each occurrence, never mere identifier text — so * shadowing neither hides a real capability nor false-positives a same-named * local (NET-S1/NET-S2): - * - global `fetch`: a `.fetch` member on a global receiver name - * (globalThis/window/self/global, incl. a statically-resolved `['fetch']`) - * that is itself FREE here — a lexically-shadowing local receiver - * (`function f(globalThis){ globalThis.fetch(...) }`) is an ordinary object and is - * allowed; a destructuring of `fetch` OFF such a free global receiver; or a bare - * `fetch` reference that is FREE at that occurrence (no lexical binding named - * `fetch` is visible). An alias `const f = fetch` is caught at the `fetch` - * reference; forwarding a global receiver (`const g = globalThis`) is already - * rejected by RC (e). - * - node:http client APIs: `request` / `get` / `ClientRequest` reached through a - * binding THIS module imported from `node:http` — a default or namespace binding - * (`http.request`, `h.get`, `new http.ClientRequest()`), a named import used bare - * (`request()`, `new ClientRequest()`), an aliased named import (`req()` from - * `{ request as req }`), or an import-equals binding — where the receiver/name - * still LEXICALLY resolves to that import. `createServer` is never in the rejected - * member set, so the real host server is preserved, and `map.get(...)` / - * `obj.request(...)` / an unrelated local `new ClientRequest()` on any - * non-node:http binding is untouched. + * - a network GLOBAL (`fetch`/`WebSocket`): a `.` member on a global + * receiver name (globalThis/window/self/global, incl. a statically-resolved + * `['fetch']`) that is itself FREE here — a lexically-shadowing local + * receiver (`function f(globalThis){ globalThis.fetch(...) }`) is an ordinary + * object and is allowed; a destructuring of the name OFF such a free global + * receiver; or a bare reference (`fetch(...)`, `new WebSocket(...)`) that is FREE + * at that occurrence (no lexical binding of the name is visible). An alias + * `const f = fetch` is caught at the `fetch` reference; forwarding a global + * receiver (`const g = globalThis`) is already rejected by RC (e). + * - node:http value capabilities: any member OTHER than `createServer` reached + * through a binding THIS module imported from `node:http` — a default or namespace + * binding (`http.request`, `h.get`, `new http.ClientRequest()`, `new http.Agent()`), + * a named import used bare (`request()`, `new ClientRequest()`, `new Agent()`), an + * aliased named import (`req()` from `{ request as req }`), or an import-equals + * binding — where the receiver/name still LEXICALLY resolves to that import. + * `createServer` is the sole allowed value member, so the real host server is + * preserved; type-only `http.Server`/`IncomingMessage`/`ServerResponse` are + * QualifiedName nodes (never value member accesses) and stay untouched, as do + * `map.get(...)` / `obj.request(...)` / an unrelated local `new ClientRequest()` + * on any non-node:http binding. * * Binding identity is resolved by a bounded lexical ENVIRONMENT STACK tied to the * AST walk: each scope (module, function/arrow/method params, block, for-header, @@ -832,16 +842,27 @@ const acquiresHiddenBuiltin = (source: string): boolean => { * generated code (RC/HA cover codegen). `node:https`/`net`/`tls` stay out of scope * — the import allowlist already blocks them. */ -const HTTP_CLIENT_MEMBERS: ReadonlySet = new Set(['request', 'get', 'ClientRequest']); -const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch']); +// The ONLY node:http VALUE export the read-only host legitimately needs is the +// inbound-server constructor `createServer`. Every other value member/name reached +// through a node:http binding is a non-server (outbound/connection) capability and is +// rejected — a finite POSITIVE model, so a client API noticed later (Agent, +// ClientRequest, request, get, …) needs no new entry. Type-only references +// (`http.Server`/`IncomingMessage`/`ServerResponse`) are QualifiedName nodes, never +// value member accesses, so they are structurally untouched. +const HTTP_SERVER_VALUE_MEMBERS: ReadonlySet = new Set(['createServer']); +// Importless network-initiating globals present on the Node target. `node:https`/ +// `net`/`tls`/`dgram`/`http2` are import-blocked by the allowlist, so this is the +// COMPLETE importless-egress global surface — a bounded family, not an open blacklist. +const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']); interface HttpBindings { // Local names bound to the node:http MODULE (`import http` / `import * as h` / // `import http = require('node:http')`), used as `binding.request(...)`. readonly namespaceOrDefault: ReadonlySet; - // Local names bound to a node:http CLIENT capability (`import { request, get as g, - // ClientRequest }`), used bare as `request(...)` / `g(...)` / `new ClientRequest()`. - // A named `createServer` is deliberately NOT collected — it is legitimate. + // Local names bound to a NON-`createServer` node:http named value (`import + // { request, get as g, ClientRequest, Agent }`), used bare as `request(...)` / + // `g(...)` / `new ClientRequest()` / `new Agent()`. A named `createServer` is + // deliberately NOT collected — it is the one legitimate value export. readonly namedClient: ReadonlySet; } @@ -869,11 +890,13 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { // `import * as http from 'node:http'`. namespaceOrDefault.add(bindings.name.text); } else { - // `import { request, get as g } from 'node:http'` — collect only the - // client members, under their LOCAL name (`el.name`). + // `import { createServer, request, Agent as A } from 'node:http'` — collect + // every named import EXCEPT `createServer` (the sole allowed value export), + // under its LOCAL name (`el.name`). The positive model treats any other + // node:http named value as a non-server capability. for (const el of bindings.elements) { const imported = (el.propertyName ?? el.name).text; - if (HTTP_CLIENT_MEMBERS.has(imported)) namedClient.add(el.name.text); + if (!HTTP_SERVER_VALUE_MEMBERS.has(imported)) namedClient.add(el.name.text); } } } @@ -894,8 +917,8 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { // A lexical binding kind for the identity-sensitive detection below. `HTTP_NS` and // `HTTP_CLIENT` mark the module-level node:http import bindings (namespace/default, -// and named `request`/`get`/`ClientRequest` respectively); `LOCAL` marks any OTHER -// declaration +// and any named node:http value that is not `createServer` respectively); `LOCAL` +// marks any OTHER declaration // (parameter, const/let/var, function/class, catch, or unrelated import) that // SHADOWS an outer binding of the same name. Capability identity is therefore the // binding VISIBLE at an occurrence, never mere identifier text (NET-S1/NET-S2). @@ -1060,11 +1083,13 @@ const usesOutboundNetwork = (source: string): boolean => { // shadows it). A shadowing local (param/const/…) makes it an ordinary // object, so `function f(globalThis) { globalThis.fetch(...) }` is allowed. if (NETWORK_GLOBAL_NAMES.has(member) && isLexicalGlobalReceiver(node.expression)) found = true; - // (d) `.request` / `.get` / `.ClientRequest` only when the receiver - // identifier LEXICALLY resolves to the module's node:http namespace/default - // import (not a shadowing local). `createServer` is never a client member. + // (d) any NON-`createServer` value member (`.request`/`.get`/`.ClientRequest`/ + // `.Agent`/…) only when the receiver identifier LEXICALLY resolves to the + // module's node:http namespace/default import (not a shadowing local). + // `createServer` is the sole allowed value member; type-only + // `http.ServerResponse` etc. are QualifiedName, not member accesses. const recv = unwrapExpr(node.expression); - if (HTTP_CLIENT_MEMBERS.has(member) && ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') { + if (!HTTP_SERVER_VALUE_MEMBERS.has(member) && ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') { found = true; } } @@ -1089,12 +1114,14 @@ const usesOutboundNetwork = (source: string): boolean => { const isBindingPropertyKey = ts.isBindingElement(node.parent) && node.parent.propertyName === node; if (!isBindingPropertyKey) { const kind = resolve(node.text); - // (c) a bare `fetch` reference that is FREE here — no lexical binding named - // `fetch` is visible — so it is the global. A local shadow resolves to - // LOCAL and is allowed; `const f = fetch` is still caught at `fetch`. + // (c) a bare network-global reference (`fetch`, `WebSocket`) that is FREE here + // — no lexical binding of the name is visible — so it is the global. A + // local shadow resolves to LOCAL and is allowed; `const f = fetch` is still + // caught at `fetch`. if (NETWORK_GLOBAL_NAMES.has(node.text) && kind === undefined) found = true; - // (e) a bare reference that lexically resolves to a node:http named client - // import (`request(...)`, `req(...)`); a shadowing local resolves LOCAL. + // (e) a bare reference that lexically resolves to a NON-`createServer` node:http + // named import (`request(...)`, `req(...)`, `new Agent()`); a shadowing + // local resolves LOCAL. if (kind === 'HTTP_CLIENT') found = true; } } @@ -3696,12 +3723,13 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI // --------------------------------------------------------------------------- // NET — the host must perform no outbound network egress (D3-CX-POLICY-NET). -// The two routes that survive every OTHER purity check are the global `fetch` -// (needs no import) and `request`/`get` reached through the already-allowed -// `node:http` module. `usesOutboundNetwork` closes exactly those, receiver-scoped, -// while preserving `http.createServer` and every unrelated `.get`/`.request`. -// node:https/net/tls stay covered by the import allowlist (D3-CX-POLICY-3), not -// here — see the detector's doc comment. +// The routes that survive every OTHER purity check are the importless network +// globals (`fetch`/`WebSocket`) and any NON-`createServer` value reached through the +// already-allowed `node:http` module. `usesOutboundNetwork` closes both by a finite +// model — a bounded global family plus a POSITIVE node:http allow of exactly +// `createServer` — receiver-scoped, while preserving `http.createServer` and every +// unrelated `.get`/`.request`. node:https/net/tls stay covered by the import allowlist +// (D3-CX-POLICY-3), not here — see the detector's doc comment. // --------------------------------------------------------------------------- describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { it('accepts every real host source (no egress is present today)', () => { @@ -3748,6 +3776,16 @@ describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { { form: 'a default node:http binding new .ClientRequest', source: `import http from 'node:http';\nnew http.ClientRequest('http://example.com/').end();` }, { form: 'an import-equals node:http binding new .ClientRequest', source: `import http = require('node:http');\nnew http.ClientRequest('http://example.com/').end();` }, { form: 'a statically-keyed ClientRequest member on a node:http binding', source: `import * as http from 'node:http';\nnew http['ClientRequest']('http://example.com/').end();` }, + // node:http connection APIs beyond the request/get/ClientRequest names — caught by + // the POSITIVE model (only `createServer` is allowed) with no per-name entry. + { form: 'a namespace node:http binding new .Agent', source: `import * as http from 'node:http';\nvoid new http.Agent();` }, + { form: 'a default node:http binding new .Agent', source: `import http from 'node:http';\nvoid new http.Agent();` }, + { form: 'an import-equals node:http binding new .Agent', source: `import http = require('node:http');\nvoid new http.Agent();` }, + { form: 'an Agent.createConnection outbound chain', source: `import * as http from 'node:http';\nnew http.Agent().createConnection({ host: 'example.com', port: 80 });` }, + { form: 'a named Agent import constructed bare', source: `import { Agent } from 'node:http';\nvoid new Agent();` }, + { form: 'an aliased named Agent import constructed bare', source: `import { Agent as A } from 'node:http';\nvoid new A();` }, + { form: 'a statically-keyed Agent member on a node:http binding', source: `import * as http from 'node:http';\nvoid new http['Agent']();` }, + { form: 'a namespace node:http binding .globalAgent read', source: `import * as http from 'node:http';\nvoid http.globalAgent;` }, ]; for (const { form, source } of rejectHttp) { it(`rejects ${form}`, () => { @@ -3779,6 +3817,11 @@ describe('D3 host forbids outbound network egress (D3-CX-POLICY-NET)', () => { { form: 'an unrelated local class named ClientRequest', source: `class ClientRequest {}\nvoid new ClientRequest();` }, { form: 'an unrelated local const constructor named ClientRequest', source: `const LocalCtor = class {};\nconst ClientRequest = LocalCtor;\nvoid new ClientRequest();` }, { form: 'a ClientRequest member on a plain local object (non-node:http)', source: `const LocalCtor = class {};\nconst http = { ClientRequest: LocalCtor };\nvoid new http.ClientRequest();` }, + { form: 'an unrelated local class named Agent', source: `class Agent {}\nvoid new Agent();` }, + { form: 'an Agent member on a plain local object (non-node:http)', source: `const http = { Agent: class {} };\nvoid new http.Agent();` }, + { form: 'a named Agent import shadowed by a function-local class', source: `import { Agent } from 'node:http';\nfunction f() { class Agent {} return new Agent(); }\nvoid f;` }, + { form: 'a type-only http.Agent reference alongside a real createServer', source: `import * as http from 'node:http';\nlet a: http.Agent | null = null;\nvoid a;\nhttp.createServer(() => {});` }, + { form: 'a type-only named Agent import used only in a type position', source: `import { Agent } from 'node:http';\nlet a: Agent | null = null;\nvoid a;` }, { form: 'the real server.ts createServer shape (handler param named request)', source: @@ -3940,3 +3983,43 @@ describe('D3 host network egress uses lexical binding identity, not name text (D }); } }); + +// --------------------------------------------------------------------------- +// NET capability-family completeness (D3-CX-POLICY-NET-CAP). The detector's outbound +// surface is closed by construction, not by an ever-growing blacklist: with the import +// allowlist blocking node:https/net/tls/dgram/http2, the only importless network route +// is a built-in network GLOBAL — `fetch` and, on the Node target, `WebSocket` — and the +// only node:http route is a NON-`createServer` value (decided by the POSITIVE model). +// These cases pin the second global, `WebSocket`, with the SAME lexical-binding identity +// used for `fetch`: a free global is rejected, a same-named local shadow is allowed. +// --------------------------------------------------------------------------- +describe('D3 host outbound-network surface is a bounded capability family (D3-CX-POLICY-NET-CAP)', () => { + const wsReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a bare global WebSocket constructor', source: `new WebSocket('wss://example.com/');` }, + { form: 'an assigned global WebSocket', source: `const ws = new WebSocket('wss://example.com/');\nvoid ws;` }, + { form: 'a globalThis.WebSocket receiver', source: `new globalThis.WebSocket('wss://example.com/');` }, + { form: 'a window.WebSocket receiver', source: `new window.WebSocket('wss://example.com/');` }, + { form: 'a self.WebSocket receiver', source: `new self.WebSocket('wss://example.com/');` }, + { form: 'a statically-keyed globalThis["WebSocket"]', source: `new globalThis['WebSocket']('wss://example.com/');` }, + { form: 'an aliased global WebSocket acquisition (const W = WebSocket)', source: `const W = WebSocket;\nnew W('wss://example.com/');` }, + { form: 'a real global WebSocket after a shadowing scope closes', source: `function f(WebSocket: new () => unknown) {\n return new WebSocket();\n}\nnew WebSocket('wss://example.com/');` }, + ]; + for (const { form, source } of wsReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const wsAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an unrelated local class named WebSocket', source: `class WebSocket {}\nvoid new WebSocket();` }, + { form: 'an unrelated local const constructor named WebSocket', source: `const WebSocket = class {};\nvoid new WebSocket();` }, + { form: 'a parameter-shadowed WebSocket used within that function', source: `function f(WebSocket: new () => unknown) {\n return new WebSocket();\n}` }, + { form: 'a WebSocket member on a plain local object (non-global receiver)', source: `const rt = { WebSocket: class {} };\nvoid new rt.WebSocket();` }, + { form: 'a named function-expression WebSocket recursion shadowing the global', source: `const helper = function WebSocket(): unknown {\n return new WebSocket();\n};\nvoid helper;` }, + ]; + for (const { form, source } of wsAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); From 86e4b9fffe6dfa377f4cd4af86ac216a6a43d35a Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 10:25:29 +0200 Subject: [PATCH 04/35] test(cockpit): enforce http capability non-escape --- tests/cockpit-host/purity.test.ts | 366 +++++++++++++++++++++++++++++- 1 file changed, 357 insertions(+), 9 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 8d10e73..6445642 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -337,6 +337,19 @@ const unwrapExpr = (node: ts.Expression): ts.Expression => { return cur; }; +// Whether an initializer is a STATICALLY-RESOLVED runtime import of node:http — +// `import('node:http')` or `await import('node:http')` (parens/`as`/await unwrapped). +// A computed/dynamic specifier is not matched (it is already failed-closed by +// `hasUnverifiableDynamicImport`); only the exact allow-listed literal acquires the +// node:http namespace capability, so it gains the SAME identity as `import * as http`. +const isNodeHttpDynamicImport = (expr: ts.Expression): boolean => { + let cur = unwrapExpr(expr); + while (ts.isAwaitExpression(cur)) cur = unwrapExpr(cur.expression); + if (!ts.isCallExpression(cur) || cur.expression.kind !== ts.SyntaxKind.ImportKeyword) return false; + const arg: ts.Expression | undefined = cur.arguments[0]; + return arg !== undefined && ts.isStringLiteralLike(arg) && arg.text === 'node:http'; +}; + const isGlobalReceiver = (node: ts.Expression): boolean => { const n = unwrapExpr(node); return ts.isIdentifier(n) && GLOBAL_RECEIVER_NAMES.has(n.text); @@ -580,6 +593,40 @@ const servesAsAccessObject = (node: ts.Node): boolean => { return (ts.isPropertyAccessExpression(p) || ts.isElementAccessExpression(p)) && p.expression === cur; }; +// NET-LOCAL: whether an HTTP_NS identifier occurrence is a forbidden ESCAPE of the +// privileged node:http namespace — i.e. NOT one of the three permitted positions: +// (1) the access-object receiver of a member/element access (a permitted createServer +// access is then decided by the member rule); +// (2) the initializer of an object-binding-pattern destructuring (F2 decides members); +// (3) a type/non-runtime position — the LEFT of a type QualifiedName, a `typeof` type +// query, an import-type, or the operand of the runtime `typeof` operator. +// Intentionally local to NET; it does NOT change the shared `isValueReference` or RC/HA. +const isHttpNamespaceEscape = (id: ts.Identifier): boolean => { + if (servesAsAccessObject(id)) return false; + const par = id.parent as ts.Node | undefined; + if ( + par !== undefined && + ts.isVariableDeclaration(par) && + ts.isObjectBindingPattern(par.name) && + par.initializer === id + ) { + return false; + } + let cur: ts.Node = id; + let gp = cur.parent as ts.Node | undefined; + while (gp !== undefined && ts.isQualifiedName(gp)) { + cur = gp; + gp = cur.parent as ts.Node | undefined; + } + if ( + gp !== undefined && + (ts.isTypeQueryNode(gp) || ts.isTypeReferenceNode(gp) || ts.isImportTypeNode(gp) || ts.isTypeOfExpression(gp)) + ) { + return false; + } + return true; +}; + /** * RC — reject runtime code generation at its acquisition site (D3-CX-POLICY-RC v2). * @@ -915,6 +962,60 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { return { namespaceOrDefault, namedClient }; }; +// Design D — node:http capability EXPORT confinement. The privileged node:http +// namespace/capability may not cross the D3 module boundary: reject any re-export FROM +// node:http (`export * from 'node:http'`, `export { … } from 'node:http'`) and any export +// of a LOCAL binding whose lexical identity is HTTP_NS (`export { http }`, +// `export { http as h }`, `export default http`, `export = http`) — createServer included. +// Ordinary local exports and type-only exports are untouched. Inspects only THIS module's +// statements and local binding identity: no cross-module value-flow. +const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { + const NODE_HTTP = 'node:http'; + const httpNs = new Set(); + for (const s of sourceFile.statements) { + if ( + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === NODE_HTTP && + s.importClause !== undefined + ) { + const clause = s.importClause; + if (clause.name !== undefined) httpNs.add(clause.name.text); + if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) { + httpNs.add(clause.namedBindings.name.text); + } + } else if ( + ts.isImportEqualsDeclaration(s) && + ts.isExternalModuleReference(s.moduleReference) && + ts.isStringLiteral(s.moduleReference.expression) && + s.moduleReference.expression.text === NODE_HTTP + ) { + httpNs.add(s.name.text); + } else if (ts.isVariableStatement(s)) { + for (const decl of s.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer !== undefined && isNodeHttpDynamicImport(decl.initializer)) { + httpNs.add(decl.name.text); + } + } + } + } + for (const s of sourceFile.statements) { + // (a) any re-export whose specifier is node:http (`export * from` / `export { … } from`). + if (ts.isExportDeclaration(s) && s.moduleSpecifier !== undefined && ts.isStringLiteral(s.moduleSpecifier) && s.moduleSpecifier.text === NODE_HTTP) { + return true; + } + // (b) a local re-export of an HTTP_NS binding (`export { http }` / `export { http as h }`). + if (ts.isExportDeclaration(s) && s.moduleSpecifier === undefined && s.exportClause !== undefined && ts.isNamedExports(s.exportClause)) { + for (const el of s.exportClause.elements) { + if (httpNs.has((el.propertyName ?? el.name).text)) return true; + } + } + // (c) `export default http` / `export = http`. + if (ts.isExportAssignment(s) && ts.isIdentifier(s.expression) && httpNs.has(s.expression.text)) return true; + } + return false; +}; + // A lexical binding kind for the identity-sensitive detection below. `HTTP_NS` and // `HTTP_CLIENT` mark the module-level node:http import bindings (namespace/default, // and any named node:http value that is not `createServer` respectively); `LOCAL` @@ -953,6 +1054,52 @@ const declaredByStatement = (statement: ts.Statement, sink: (text: string) => vo } }; +// F1/F2 node:http capability PROPAGATION, reusing binding identity (never text): +// F1 `const http = (await) import('node:http')` -> `http` is HTTP_NS, exactly as a +// namespace import would be. +// F2 `const { request, get: g, createServer: cs } = ` -> each destructured +// member preserves the namespace capability: `createServer` (the one allowed +// server value) stays LOCAL, every other member is HTTP_CLIENT (outbound). The +// RHS must LEXICALLY resolve to HTTP_NS, so `const { request } = someLocalObject` +// is untouched. Bounded: one binding-pattern level, static keys only; deeper +// nesting and genuinely computed keys are unsupported (documented) gaps. +const propagateHttpCapability = ( + decl: ts.VariableDeclaration, + lookup: (name: string) => BindingKind | undefined, + sink: (name: string, kind: BindingKind) => void, +): void => { + if (decl.initializer === undefined) return; + if (ts.isIdentifier(decl.name)) { + if (isNodeHttpDynamicImport(decl.initializer)) sink(decl.name.text, 'HTTP_NS'); + return; + } + if (ts.isObjectBindingPattern(decl.name)) { + // The RHS is the node:http namespace when it is a lexically-HTTP_NS identifier + // OR the dynamic import itself (`const { request } = await import('node:http')`, + // the direct F1+F2 composition). + const rhs = unwrapExpr(decl.initializer); + const rhsIsHttpNs = + isNodeHttpDynamicImport(decl.initializer) || (ts.isIdentifier(rhs) && lookup(rhs.text) === 'HTTP_NS'); + if (!rhsIsHttpNs) return; + for (const el of decl.name.elements) { + if (!ts.isIdentifier(el.name)) continue; // nested pattern: unsupported + const key = el.propertyName; + const member = + key === undefined + ? el.name.text + : ts.isIdentifier(key) + ? key.text + : ts.isStringLiteralLike(key) + ? key.text + : ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression) + ? key.expression.text + : null; + if (member === null) continue; // genuinely computed key: unsupported + sink(el.name.text, HTTP_SERVER_VALUE_MEMBERS.has(member) ? 'LOCAL' : 'HTTP_CLIENT'); + } + } +}; + // The module (top-level) lexical frame: node:http import bindings tagged with their // capability kind, every other top-level binding tagged LOCAL. This is the outermost // frame for the whole file, so a genuine module-level `const fetch` shadows the @@ -980,6 +1127,15 @@ const buildModuleFrame = (sourceFile: ts.SourceFile, http: HttpBindings): Map frame.get(n), (name, kind) => frame.set(name, kind)); + } + } + } return frame; }; @@ -1050,6 +1206,15 @@ const usesOutboundNetwork = (source: string): boolean => { if (ts.isBlock(node) || ts.isModuleBlock(node)) { const frame = new Map(); for (const statement of node.statements) declaredByStatement(statement, (t) => frame.set(t, 'LOCAL')); + // F1/F2 capability propagation within this block, resolving a destructuring RHS + // against this frame first, then the enclosing scopes (outer HTTP_NS imports). + for (const statement of node.statements) { + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + propagateHttpCapability(decl, (n) => frame.get(n) ?? resolve(n), (name, kind) => frame.set(name, kind)); + } + } + } return frame; } if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) { @@ -1077,21 +1242,21 @@ const usesOutboundNetwork = (source: string): boolean => { if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const member = memberNameOf(node, constMap); + const recv = unwrapExpr(node.expression); + const recvIsHttpNs = ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS'; + // (d/3) an HTTP_NS receiver is permitted ONLY for a statically-PROVEN `createServer` + // member; every other known member (`.request`/`.get`/`.ClientRequest`/`.Agent`/…) + // AND any indeterminate/computed member (`http[dynamicKey]`, member === null) is + // rejected fail-closed. `createServer` is the sole allowed value member; type-only + // `http.ServerResponse` etc. are QualifiedName, not member accesses. + const provenCreateServer = member !== null && HTTP_SERVER_VALUE_MEMBERS.has(member); + if (recvIsHttpNs && !provenCreateServer) found = true; if (member !== null) { // (a) `.fetch` / statically-keyed `['fetch']` — only when the // receiver identifier is a global name that is FREE here (no local binding // shadows it). A shadowing local (param/const/…) makes it an ordinary // object, so `function f(globalThis) { globalThis.fetch(...) }` is allowed. if (NETWORK_GLOBAL_NAMES.has(member) && isLexicalGlobalReceiver(node.expression)) found = true; - // (d) any NON-`createServer` value member (`.request`/`.get`/`.ClientRequest`/ - // `.Agent`/…) only when the receiver identifier LEXICALLY resolves to the - // module's node:http namespace/default import (not a shadowing local). - // `createServer` is the sole allowed value member; type-only - // `http.ServerResponse` etc. are QualifiedName, not member accesses. - const recv = unwrapExpr(node.expression); - if (!HTTP_SERVER_VALUE_MEMBERS.has(member) && ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') { - found = true; - } } } // (b) destructuring `fetch` off a global receiver, i.e. `const { fetch: f } = @@ -1123,6 +1288,13 @@ const usesOutboundNetwork = (source: string): boolean => { // named import (`request(...)`, `req(...)`, `new Agent()`); a shadowing // local resolves LOCAL. if (kind === 'HTTP_CLIENT') found = true; + // (f) DESIGN A non-escape: an HTTP_NS value reference is permitted ONLY as an + // access-object receiver (rule d/3), a destructuring initializer (F2), or a + // NET-local type/non-runtime position; EVERY other runtime reference is a + // forbidden ESCAPE (`const h = http`, `foo(http)`, `return http`, `[http]`, + // `{ v: http }`, `{ ...http }`, `export default http`) — rejected AT the name, + // so no alias/value-flow tracking is needed. + if (kind === 'HTTP_NS' && isHttpNamespaceEscape(node)) found = true; } } @@ -4023,3 +4195,179 @@ describe('D3 host outbound-network surface is a bounded capability family (D3-CX }); } }); + +// --------------------------------------------------------------------------- +// NET capability ACQUISITION / PROPAGATION (D3-CX-POLICY-NET-ACQ). node:http is +// allow-listed, so its capability must be tracked not only through a static import +// but through the other bounded, statically-resolvable forms that acquire or +// forward that exact binding: a runtime `await import('node:http')` (F1) yields the +// same HTTP_NS identity as `import * as http`, and destructuring off an HTTP_NS +// binding (F2) preserves capability per member — `createServer` stays the one allowed +// server value, every other member is outbound. Decided by the same lexical binding +// stack (a local shadow or a non-node:http receiver is untouched); alias-via-plain- +// assignment and genuinely computed forms remain documented, bounded gaps. +// --------------------------------------------------------------------------- +describe('D3 host tracks node:http capability through dynamic import and destructuring (D3-CX-POLICY-NET-ACQ)', () => { + // F1 — a statically-resolved `import('node:http')` acquires the namespace capability. + const f1Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a dynamic-import namespace .request', source: `const http = await import('node:http');\nhttp.request('http://example.com/').end();` }, + { form: 'a dynamic-import namespace .get', source: `const http = await import('node:http');\nhttp.get('http://example.com/');` }, + { form: 'a dynamic-import namespace new .ClientRequest', source: `const http = await import('node:http');\nnew http.ClientRequest('http://example.com/');` }, + { form: 'a dynamic-import namespace new .Agent', source: `const http = await import('node:http');\nvoid new http.Agent();` }, + { form: 'a dynamic-import namespace statically-keyed member', source: `const http = await import('node:http');\nhttp['request']('http://example.com/');` }, + { form: 'a parenthesized awaited dynamic import', source: `const http = (await import('node:http'));\nhttp.get('http://example.com/');` }, + ]; + for (const { form, source } of f1Reject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const f1Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a dynamic-import namespace createServer', source: `const http = await import('node:http');\nhttp.createServer(() => {});` }, + { form: 'a dynamic-import http shadowed by a nested local', source: `const http = await import('node:http');\nfunction f() {\n const http = { request(v: string) { return v; } };\n return http.request('local');\n}\nvoid f;` }, + { form: 'an unrelated relative dynamic import', source: `const m = await import('./local.js');\nvoid m;` }, + { form: 'a node:url dynamic import used for pathToFileURL', source: `const u = await import('node:url');\nvoid u.pathToFileURL('x');` }, + ]; + for (const { form, source } of f1Allow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // F2 — destructuring a NON-createServer member off an HTTP_NS binding is outbound. + const f2Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a destructured request off a namespace import', source: `import * as http from 'node:http';\nconst { request } = http;\nrequest('http://example.com/').end();` }, + { form: 'a destructured get off a namespace import', source: `import * as http from 'node:http';\nconst { get } = http;\nget('http://example.com/');` }, + { form: 'a destructured ClientRequest off a namespace import', source: `import * as http from 'node:http';\nconst { ClientRequest } = http;\nnew ClientRequest('http://example.com/');` }, + { form: 'a destructured Agent off a namespace import', source: `import * as http from 'node:http';\nconst { Agent } = http;\nvoid new Agent();` }, + { form: 'an aliased destructured request', source: `import * as http from 'node:http';\nconst { request: r } = http;\nr('http://example.com/');` }, + { form: 'a statically-computed-key destructured request', source: `import * as http from 'node:http';\nconst { ['request']: r } = http;\nr('http://example.com/');` }, + { form: 'a destructured request off a default import', source: `import http from 'node:http';\nconst { request } = http;\nrequest('http://example.com/');` }, + { form: 'a destructured request off an import-equals binding', source: `import http = require('node:http');\nconst { request } = http;\nrequest('http://example.com/');` }, + { form: 'a destructured request off a dynamic-import namespace', source: `const http = await import('node:http');\nconst { request } = http;\nrequest('http://example.com/');` }, + { form: 'a request destructured DIRECTLY off a dynamic import', source: `const { request } = await import('node:http');\nrequest('http://example.com/');` }, + { form: 'a destructured request inside a nested block', source: `import * as http from 'node:http';\nfunction f() {\n const { request } = http;\n return request('http://example.com/');\n}\nvoid f;` }, + ]; + for (const { form, source } of f2Reject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const f2Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a destructured createServer off a namespace import', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, + { form: 'an aliased destructured createServer', source: `import * as http from 'node:http';\nconst { createServer: cs } = http;\ncs(() => {});` }, + { form: 'createServer destructured DIRECTLY off a dynamic import', source: `const { createServer } = await import('node:http');\ncreateServer(() => {});` }, + { form: 'a destructured request off a plain local object named http', source: `const http = { request(x: string) { return x; } };\nconst { request } = http;\nvoid request('a');` }, + { form: 'a destructured request off an unrelated local object', source: `const cfg = { request: (x: string) => x };\nconst { request } = cfg;\nvoid request('a');` }, + { form: 'a destructured capability that does not leak to a sibling scope', source: `import * as http from 'node:http';\nfunction b(request: (x: string) => string) {\n return request('local');\n}\nvoid b;` }, + ]; + for (const { form, source } of f2Allow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // Combined: a destructured capability is rejected where visible, without tainting a + // same-named local parameter in a sibling scope. + it('REJECTS the destructured import use while ALLOWING a same-named sibling param', () => { + expect( + usesOutboundNetwork( + `import * as http from 'node:http';\nfunction a() {\n const { request } = http;\n return request('http://example.com/');\n}\nfunction b(request: (x: string) => string) {\n return request('local');\n}\nvoid a;\nvoid b;`, + ), + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// NET namespace NON-ESCAPE (D3-CX-POLICY-NET-ESCAPE). The privileged node:http +// namespace (HTTP_NS) may appear at runtime ONLY as a createServer access, a +// destructuring that extracts createServer, or a type reference. Every other runtime +// reference — alias, argument, return, array/object element, spread, default-export — +// is a rejected ESCAPE, decided at the original occurrence (no alias/value-flow). +// An HTTP_NS member access is permitted only for a statically-proven createServer; +// an indeterminate/computed member is rejected fail-closed. +// --------------------------------------------------------------------------- +describe('D3 host forbids escape of the node:http namespace capability (D3-CX-POLICY-NET-ESCAPE)', () => { + const escapeReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'aliasing the namespace to a const', source: `import * as http from 'node:http';\nconst h = http;\nvoid h;` }, + { form: 'passing the namespace as an argument', source: `import * as http from 'node:http';\ndeclare function foo(x: unknown): void;\nfoo(http);` }, + { form: 'returning the namespace', source: `import * as http from 'node:http';\nexport function g(): unknown { return http; }` }, + { form: 'storing the namespace in an array', source: `import * as http from 'node:http';\nconst a = [http];\nvoid a;` }, + { form: 'storing the namespace in an object property', source: `import * as http from 'node:http';\nconst o = { value: http };\nvoid o;` }, + { form: 'spreading the namespace into an object', source: `import * as http from 'node:http';\nconst o = { ...http };\nvoid o;` }, + { form: 'default-exporting the namespace', source: `import * as http from 'node:http';\nexport default http;` }, + { form: 'aliasing a dynamic-import namespace', source: `const http = await import('node:http');\nconst h = http;\nvoid h;` }, + { form: 'an indeterminate computed member on the namespace', source: `import * as http from 'node:http';\ndeclare const k: string;\nvoid http[k];` }, + { form: 'a runtime-conditional computed member on the namespace', source: `import * as http from 'node:http';\nconst k = Math.random() > 0.5 ? 'createServer' : 'request';\nvoid http[k];` }, + ]; + for (const { form, source } of escapeReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const escapeAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a createServer access on the namespace', source: `import * as http from 'node:http';\nhttp.createServer(() => {});` }, + { form: 'a statically-keyed createServer access', source: `import * as http from 'node:http';\nhttp['createServer'](() => {});` }, + { form: 'extracting createServer to a const via access', source: `import * as http from 'node:http';\nconst cs = http.createServer;\ncs(() => {});` }, + { form: 'a createServer destructuring', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, + { form: 'a type reference to the namespace member', source: `import * as http from 'node:http';\nexport type S = http.Server;\nhttp.createServer(() => {});` }, + { form: 'a typeof type query of the namespace', source: `import * as http from 'node:http';\nexport type T = typeof http;\nhttp.createServer(() => {});` }, + { form: 'a runtime typeof of the namespace', source: `import * as http from 'node:http';\nconst t = typeof http;\nvoid t;\nhttp.createServer(() => {});` }, + ]; + for (const { form, source } of escapeAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + +// --------------------------------------------------------------------------- +// NET module-boundary export confinement (D3-CX-POLICY-NET-EXPORT). The privileged +// node:http capability may not cross the D3 module boundary: no re-export from +// node:http and no export of a local HTTP_NS binding (createServer included). Ordinary +// local exports and type-only exports are untouched. `exportsHttpCapability` inspects +// only THIS module's statements and local binding identity (no cross-module data-flow). +// --------------------------------------------------------------------------- +describe('D3 host may not export node:http capability across the module boundary (D3-CX-POLICY-NET-EXPORT)', () => { + it('accepts every real host source (no host source exports node:http capability)', () => { + for (const { file, text } of hostSources()) { + const sf = ts.createSourceFile('module.ts', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf), `${file} exports node:http capability`).toBe(false); + } + }); + + const exportReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a star re-export from node:http', source: `export * from 'node:http';` }, + { form: 'a named re-export from node:http', source: `export { request } from 'node:http';` }, + { form: 'a createServer re-export from node:http', source: `export { createServer } from 'node:http';` }, + { form: 'a default-aliased re-export from node:http', source: `export { default as http } from 'node:http';` }, + { form: 'exporting a namespace import binding', source: `import * as http from 'node:http';\nexport { http };` }, + { form: 'exporting an aliased namespace binding', source: `import * as http from 'node:http';\nexport { http as h };` }, + { form: 'default-exporting a namespace binding', source: `import * as http from 'node:http';\nexport default http;` }, + { form: 'exporting a dynamic-import namespace binding', source: `const http = await import('node:http');\nexport { http };` }, + { form: 'export-equals of a namespace binding', source: `import http = require('node:http');\nexport = http;` }, + ]; + for (const { form, source } of exportReject) { + it(`REJECTS ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(true); + }); + } + + const exportAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ordinary const export', source: `export const HOST = '127.0.0.1';` }, + { form: 'an ordinary function export that uses createServer internally', source: `import http from 'node:http';\nexport function make() { return http.createServer(() => {}); }` }, + { form: 'exporting a created server instance', source: `import http from 'node:http';\nexport const server = http.createServer(() => {});` }, + { form: 'a relative re-export', source: `export { foo } from './local.js';` }, + { form: 'a type-only export of a namespace member type', source: `import * as http from 'node:http';\nexport type S = http.Server;` }, + ]; + for (const { form, source } of exportAllow) { + it(`ALLOWS ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(false); + }); + } +}); From d9c0dc665cc578359e5a8fad107bcab3bbbf04c8 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 11:57:00 +0200 Subject: [PATCH 05/35] test(cockpit): close http authority coverage gaps --- tests/cockpit-host/purity.test.ts | 120 +++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 6445642..975e6b0 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -971,7 +971,10 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { // statements and local binding identity: no cross-module value-flow. const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { const NODE_HTTP = 'node:http'; + // Bindings carrying node:http RUNTIME authority: the namespace (HTTP_NS) and any + // non-createServer named CLIENT value (HTTP_CLIENT). Type-only imports carry none. const httpNs = new Set(); + const httpClient = new Set(); for (const s of sourceFile.statements) { if ( ts.isImportDeclaration(s) && @@ -980,9 +983,19 @@ const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { s.importClause !== undefined ) { const clause = s.importClause; + if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; if (clause.name !== undefined) httpNs.add(clause.name.text); - if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) { - httpNs.add(clause.namedBindings.name.text); + const bindings = clause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + httpNs.add(bindings.name.text); + } else { + for (const el of bindings.elements) { + if (el.isTypeOnly) continue; + const imported = (el.propertyName ?? el.name).text; + if (imported !== 'createServer') httpClient.add(el.name.text); + } + } } } else if ( ts.isImportEqualsDeclaration(s) && @@ -999,19 +1012,42 @@ const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { } } } + const carriesAuthority = (name: string): boolean => httpNs.has(name) || httpClient.has(name); for (const s of sourceFile.statements) { - // (a) any re-export whose specifier is node:http (`export * from` / `export { … } from`). - if (ts.isExportDeclaration(s) && s.moduleSpecifier !== undefined && ts.isStringLiteral(s.moduleSpecifier) && s.moduleSpecifier.text === NODE_HTTP) { + // (a) a RUNTIME re-export from node:http (a type-only re-export carries no authority). + if ( + ts.isExportDeclaration(s) && + !s.isTypeOnly && + s.moduleSpecifier !== undefined && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === NODE_HTTP + ) { return true; } - // (b) a local re-export of an HTTP_NS binding (`export { http }` / `export { http as h }`). - if (ts.isExportDeclaration(s) && s.moduleSpecifier === undefined && s.exportClause !== undefined && ts.isNamedExports(s.exportClause)) { + // (b) a local re-export of a binding carrying node:http authority (namespace OR a + // named client value). Type-only export specifiers are skipped. + if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.moduleSpecifier === undefined && s.exportClause !== undefined && ts.isNamedExports(s.exportClause)) { for (const el of s.exportClause.elements) { - if (httpNs.has((el.propertyName ?? el.name).text)) return true; + if (!el.isTypeOnly && carriesAuthority((el.propertyName ?? el.name).text)) return true; } } // (c) `export default http` / `export = http`. - if (ts.isExportAssignment(s) && ts.isIdentifier(s.expression) && httpNs.has(s.expression.text)) return true; + if (ts.isExportAssignment(s) && ts.isIdentifier(s.expression) && carriesAuthority(s.expression.text)) return true; + // (d) an EXPORTED runtime declaration that ESTABLISHES node:http authority in the + // same statement (`export const http = await import('node:http')`, + // `export const { request } = http`) — reuse the capability-propagation rule; a + // declaration yielding only createServer/LOCAL is not authority. + if (ts.isVariableStatement(s) && ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true) { + for (const decl of s.declarationList.declarations) { + const established: BindingKind[] = []; + propagateHttpCapability( + decl, + (n) => (httpNs.has(n) ? 'HTTP_NS' : httpClient.has(n) ? 'HTTP_CLIENT' : undefined), + (_name, kind) => established.push(kind), + ); + if (established.some((k) => k === 'HTTP_NS' || k === 'HTTP_CLIENT')) return true; + } + } } return false; }; @@ -1243,7 +1279,8 @@ const usesOutboundNetwork = (source: string): boolean => { if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const member = memberNameOf(node, constMap); const recv = unwrapExpr(node.expression); - const recvIsHttpNs = ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS'; + const recvIsHttpNs = + (ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') || isNodeHttpDynamicImport(recv); // (d/3) an HTTP_NS receiver is permitted ONLY for a statically-PROVEN `createServer` // member; every other known member (`.request`/`.get`/`.ClientRequest`/`.Agent`/…) // AND any indeterminate/computed member (`http[dynamicKey]`, member === null) is @@ -4371,3 +4408,68 @@ describe('D3 host may not export node:http capability across the module boundary }); } }); + +// --------------------------------------------------------------------------- +// NET direct dynamic-import receiver + runtime-export completeness +// (D3-CX-POLICY-NET-DIRECT). Finite completeness of the agreed model: a statically- +// resolved `import('node:http')` is HTTP_NS authority even as a direct expression +// receiver (no binding); a named node:http CLIENT import and an exported dynamic-import +// declaration are node:http authority crossing the module boundary. Same lexical +// binding identity, same createServer-only rule — no new mechanism. +// --------------------------------------------------------------------------- +describe('D3 host closes direct dynamic-import receivers and runtime capability exports (D3-CX-POLICY-NET-DIRECT)', () => { + const directReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a direct dynamic-import .request', source: `(await import('node:http')).request('http://example.com/').end();` }, + { form: 'a direct dynamic-import .get', source: `(await import('node:http')).get('http://example.com/');` }, + { form: 'a direct dynamic-import new .ClientRequest', source: `new (await import('node:http')).ClientRequest('http://example.com/');` }, + { form: 'a direct dynamic-import new .Agent', source: `void new (await import('node:http')).Agent();` }, + { form: 'a direct dynamic-import indeterminate member', source: `declare const k: string;\nvoid (await import('node:http'))[k];` }, + ]; + for (const { form, source } of directReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + const directAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a direct dynamic-import createServer', source: `(await import('node:http')).createServer(() => {});` }, + { form: 'a direct dynamic-import statically-keyed createServer', source: `(await import('node:http'))['createServer'](() => {});` }, + { form: 'a direct dynamic import of an unrelated module', source: `void (await import('node:url')).pathToFileURL('x');` }, + ]; + for (const { form, source } of directAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + const exportReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a named request import re-exported', source: `import { request } from 'node:http';\nexport { request };` }, + { form: 'an aliased named request import re-exported', source: `import { request as req } from 'node:http';\nexport { req };` }, + { form: 'a named get import re-exported', source: `import { get } from 'node:http';\nexport { get };` }, + { form: 'a named ClientRequest import re-exported', source: `import { ClientRequest } from 'node:http';\nexport { ClientRequest };` }, + { form: 'a named Agent import re-exported', source: `import { Agent } from 'node:http';\nexport { Agent };` }, + { form: 'an exported dynamic-import namespace declaration', source: `export const http = await import('node:http');` }, + { form: 'an exported destructured dynamic-import client member', source: `export const { request } = await import('node:http');` }, + { form: 'an exported destructured namespace client member', source: `import * as http from 'node:http';\nexport const { request } = http;` }, + ]; + for (const { form, source } of exportReject) { + it(`REJECTS export of ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(true); + }); + } + const exportAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a named createServer import re-exported', source: `import { createServer } from 'node:http';\nexport { createServer };` }, + { form: 'an exported createServer-derived local', source: `import http from 'node:http';\nexport const server = http.createServer(() => {});` }, + { form: 'an exported destructured createServer', source: `import * as http from 'node:http';\nexport const { createServer } = http;` }, + { form: 'a type-only named import re-exported as type', source: `import type { IncomingMessage } from 'node:http';\nexport type { IncomingMessage };` }, + { form: 'an inline-type named import re-exported', source: `import { type IncomingMessage } from 'node:http';\nexport { type IncomingMessage };` }, + { form: 'a type-only star re-export from node:http', source: `export type * from 'node:http';` }, + { form: 'an ordinary application export', source: `export const HOST = '127.0.0.1';` }, + ]; + for (const { form, source } of exportAllow) { + it(`ALLOWS export of ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(false); + }); + } +}); From f2b4387cbc7b656c1c4daf5b3f932572b65480fd Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 13:03:46 +0200 Subject: [PATCH 06/35] test(cockpit): enforce static http authority --- tests/cockpit-host/purity.test.ts | 170 +++++++++++++++++++----------- 1 file changed, 108 insertions(+), 62 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 975e6b0..39954c4 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -970,51 +970,20 @@ const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { // Ordinary local exports and type-only exports are untouched. Inspects only THIS module's // statements and local binding identity: no cross-module value-flow. const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { + // OPTION B unification: consume the SAME lexical binding classification the + // network-egress analysis uses (`buildModuleFrame` over `collectHttpBindings`), so the + // export boundary can never disagree with `usesOutboundNetwork`. A binding classified + // HTTP_NS or HTTP_CLIENT — a namespace, a named client import, OR a locally-destructured + // non-createServer member — may not cross the module boundary; createServer/LOCAL and + // type-only forms may. No second name inventory. const NODE_HTTP = 'node:http'; - // Bindings carrying node:http RUNTIME authority: the namespace (HTTP_NS) and any - // non-createServer named CLIENT value (HTTP_CLIENT). Type-only imports carry none. - const httpNs = new Set(); - const httpClient = new Set(); - for (const s of sourceFile.statements) { - if ( - ts.isImportDeclaration(s) && - ts.isStringLiteral(s.moduleSpecifier) && - s.moduleSpecifier.text === NODE_HTTP && - s.importClause !== undefined - ) { - const clause = s.importClause; - if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; - if (clause.name !== undefined) httpNs.add(clause.name.text); - const bindings = clause.namedBindings; - if (bindings !== undefined) { - if (ts.isNamespaceImport(bindings)) { - httpNs.add(bindings.name.text); - } else { - for (const el of bindings.elements) { - if (el.isTypeOnly) continue; - const imported = (el.propertyName ?? el.name).text; - if (imported !== 'createServer') httpClient.add(el.name.text); - } - } - } - } else if ( - ts.isImportEqualsDeclaration(s) && - ts.isExternalModuleReference(s.moduleReference) && - ts.isStringLiteral(s.moduleReference.expression) && - s.moduleReference.expression.text === NODE_HTTP - ) { - httpNs.add(s.name.text); - } else if (ts.isVariableStatement(s)) { - for (const decl of s.declarationList.declarations) { - if (ts.isIdentifier(decl.name) && decl.initializer !== undefined && isNodeHttpDynamicImport(decl.initializer)) { - httpNs.add(decl.name.text); - } - } - } - } - const carriesAuthority = (name: string): boolean => httpNs.has(name) || httpClient.has(name); + const frame = buildModuleFrame(sourceFile, collectHttpBindings(sourceFile)); + const carriesAuthority = (name: string): boolean => { + const kind = frame.get(name); + return kind === 'HTTP_NS' || kind === 'HTTP_CLIENT'; + }; for (const s of sourceFile.statements) { - // (a) a RUNTIME re-export from node:http (a type-only re-export carries no authority). + // (a) a RUNTIME re-export from node:http (`export * from` / `export { … } from`). if ( ts.isExportDeclaration(s) && !s.isTypeOnly && @@ -1024,28 +993,20 @@ const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { ) { return true; } - // (b) a local re-export of a binding carrying node:http authority (namespace OR a - // named client value). Type-only export specifiers are skipped. + // (b) a local re-export of a binding carrying node:http authority. Type-only skipped. if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.moduleSpecifier === undefined && s.exportClause !== undefined && ts.isNamedExports(s.exportClause)) { for (const el of s.exportClause.elements) { if (!el.isTypeOnly && carriesAuthority((el.propertyName ?? el.name).text)) return true; } } - // (c) `export default http` / `export = http`. + // (c) `export default ` / `export = `. if (ts.isExportAssignment(s) && ts.isIdentifier(s.expression) && carriesAuthority(s.expression.text)) return true; - // (d) an EXPORTED runtime declaration that ESTABLISHES node:http authority in the - // same statement (`export const http = await import('node:http')`, - // `export const { request } = http`) — reuse the capability-propagation rule; a - // declaration yielding only createServer/LOCAL is not authority. + // (d) an EXPORTED declaration whose bound name(s) carry node:http authority. if (ts.isVariableStatement(s) && ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true) { for (const decl of s.declarationList.declarations) { - const established: BindingKind[] = []; - propagateHttpCapability( - decl, - (n) => (httpNs.has(n) ? 'HTTP_NS' : httpClient.has(n) ? 'HTTP_CLIENT' : undefined), - (_name, kind) => established.push(kind), - ); - if (established.some((k) => k === 'HTTP_NS' || k === 'HTTP_CLIENT')) return true; + const names: string[] = []; + eachBoundName(decl.name, (n) => names.push(n)); + if (names.some((n) => carriesAuthority(n))) return true; } } } @@ -1276,6 +1237,18 @@ const usesOutboundNetwork = (source: string): boolean => { const frame = frameFor(node); if (frame !== null) scopes.push(frame); + // OPTION B — a runtime dynamic `import('node:http')` is prohibited OUTRIGHT: the D3 + // host acquires node:http statically only. Rooted at the import expression itself, so + // it is rejected in EVERY context (receiver, argument, return, array/object/spread, + // conditional/logical, variable initializer, for-header, default export, wrappers) with + // no per-context rule. Non-node:http dynamic imports are untouched. + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const specifier = node.arguments[0]; + if (specifier !== undefined && ts.isStringLiteralLike(specifier) && specifier.text === 'node:http') { + found = true; + } + } + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const member = memberNameOf(node, constMap); const recv = unwrapExpr(node.expression); @@ -4247,6 +4220,8 @@ describe('D3 host outbound-network surface is a bounded capability family (D3-CX describe('D3 host tracks node:http capability through dynamic import and destructuring (D3-CX-POLICY-NET-ACQ)', () => { // F1 — a statically-resolved `import('node:http')` acquires the namespace capability. const f1Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a dynamic-import http shadowed by a nested local', source: `const http = await import('node:http');\nfunction f() {\n const http = { request(v: string) { return v; } };\n return http.request('local');\n}\nvoid f;` }, + { form: 'a dynamic-import namespace createServer', source: `const http = await import('node:http');\nhttp.createServer(() => {});` }, { form: 'a dynamic-import namespace .request', source: `const http = await import('node:http');\nhttp.request('http://example.com/').end();` }, { form: 'a dynamic-import namespace .get', source: `const http = await import('node:http');\nhttp.get('http://example.com/');` }, { form: 'a dynamic-import namespace new .ClientRequest', source: `const http = await import('node:http');\nnew http.ClientRequest('http://example.com/');` }, @@ -4261,8 +4236,6 @@ describe('D3 host tracks node:http capability through dynamic import and destruc } const f1Allow: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'a dynamic-import namespace createServer', source: `const http = await import('node:http');\nhttp.createServer(() => {});` }, - { form: 'a dynamic-import http shadowed by a nested local', source: `const http = await import('node:http');\nfunction f() {\n const http = { request(v: string) { return v; } };\n return http.request('local');\n}\nvoid f;` }, { form: 'an unrelated relative dynamic import', source: `const m = await import('./local.js');\nvoid m;` }, { form: 'a node:url dynamic import used for pathToFileURL', source: `const u = await import('node:url');\nvoid u.pathToFileURL('x');` }, ]; @@ -4274,6 +4247,7 @@ describe('D3 host tracks node:http capability through dynamic import and destruc // F2 — destructuring a NON-createServer member off an HTTP_NS binding is outbound. const f2Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'createServer destructured DIRECTLY off a dynamic import', source: `const { createServer } = await import('node:http');\ncreateServer(() => {});` }, { form: 'a destructured request off a namespace import', source: `import * as http from 'node:http';\nconst { request } = http;\nrequest('http://example.com/').end();` }, { form: 'a destructured get off a namespace import', source: `import * as http from 'node:http';\nconst { get } = http;\nget('http://example.com/');` }, { form: 'a destructured ClientRequest off a namespace import', source: `import * as http from 'node:http';\nconst { ClientRequest } = http;\nnew ClientRequest('http://example.com/');` }, @@ -4295,7 +4269,6 @@ describe('D3 host tracks node:http capability through dynamic import and destruc const f2Allow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a destructured createServer off a namespace import', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, { form: 'an aliased destructured createServer', source: `import * as http from 'node:http';\nconst { createServer: cs } = http;\ncs(() => {});` }, - { form: 'createServer destructured DIRECTLY off a dynamic import', source: `const { createServer } = await import('node:http');\ncreateServer(() => {});` }, { form: 'a destructured request off a plain local object named http', source: `const http = { request(x: string) { return x; } };\nconst { request } = http;\nvoid request('a');` }, { form: 'a destructured request off an unrelated local object', source: `const cfg = { request: (x: string) => x };\nconst { request } = cfg;\nvoid request('a');` }, { form: 'a destructured capability that does not leak to a sibling scope', source: `import * as http from 'node:http';\nfunction b(request: (x: string) => string) {\n return request('local');\n}\nvoid b;` }, @@ -4419,6 +4392,8 @@ describe('D3 host may not export node:http capability across the module boundary // --------------------------------------------------------------------------- describe('D3 host closes direct dynamic-import receivers and runtime capability exports (D3-CX-POLICY-NET-DIRECT)', () => { const directReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a direct dynamic-import statically-keyed createServer', source: `(await import('node:http'))['createServer'](() => {});` }, + { form: 'a direct dynamic-import createServer', source: `(await import('node:http')).createServer(() => {});` }, { form: 'a direct dynamic-import .request', source: `(await import('node:http')).request('http://example.com/').end();` }, { form: 'a direct dynamic-import .get', source: `(await import('node:http')).get('http://example.com/');` }, { form: 'a direct dynamic-import new .ClientRequest', source: `new (await import('node:http')).ClientRequest('http://example.com/');` }, @@ -4431,8 +4406,6 @@ describe('D3 host closes direct dynamic-import receivers and runtime capability }); } const directAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'a direct dynamic-import createServer', source: `(await import('node:http')).createServer(() => {});` }, - { form: 'a direct dynamic-import statically-keyed createServer', source: `(await import('node:http'))['createServer'](() => {});` }, { form: 'a direct dynamic import of an unrelated module', source: `void (await import('node:url')).pathToFileURL('x');` }, ]; for (const { form, source } of directAllow) { @@ -4473,3 +4446,76 @@ describe('D3 host closes direct dynamic-import receivers and runtime capability }); } }); + +// --------------------------------------------------------------------------- +// NET Option-B: runtime dynamic import of node:http is prohibited OUTRIGHT +// (D3-CX-POLICY-NET-OPTB). node:http authority may enter D3 only through STATIC imports; +// a runtime `import('node:http')` expression is rejected in every context, rooted at the +// import expression itself. Non-node:http dynamic imports and static createServer remain +// allowed. Export confinement consumes the same lexical binding classification. +// --------------------------------------------------------------------------- +describe('D3 host prohibits runtime dynamic import of node:http (D3-CX-POLICY-NET-OPTB)', () => { + const optbReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a bare awaited dynamic import', source: `void (await import('node:http'));` }, + { form: 'a parenthesized awaited dynamic import', source: `void ((await import('node:http')));` }, + { form: 'a dynamic import passed as an argument', source: `declare function send(x: unknown): void;\nsend(await import('node:http'));` }, + { form: 'a returned dynamic import', source: `export async function g(): Promise { return await import('node:http'); }` }, + { form: 'a dynamic import stored in an array', source: `const a = [await import('node:http')];\nvoid a;` }, + { form: 'a dynamic import stored in an object', source: `const o = { x: await import('node:http') };\nvoid o;` }, + { form: 'a dynamic import spread through an array', source: `const a = [...[await import('node:http')]];\nvoid a;` }, + { form: 'a dynamic import inside a conditional', source: `const x = true ? await import('node:http') : null;\nvoid x;` }, + { form: 'a dynamic import inside a logical expression', source: `const x = (globalThis as { f?: boolean }).f && (await import('node:http'));\nvoid x;` }, + { form: 'a for-header dynamic import binding', source: `for (const http = await import('node:http'); Math.random() > 1;) { http.request('http://example.com/'); }` }, + { form: 'a for-of over a dynamic import', source: `for (const _ of [await import('node:http')]) { void _; }` }, + { form: 'a destructuring off a dynamic import', source: `const { request } = await import('node:http');\nvoid request;` }, + { form: 'a default export of a dynamic import', source: `export default await import('node:http');` }, + ]; + for (const { form, source } of optbReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const optbAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a static default import createServer', source: `import http from 'node:http';\nhttp.createServer(() => {});` }, + { form: 'a static named createServer import', source: `import { createServer } from 'node:http';\ncreateServer(() => {});` }, + { form: 'a static createServer destructuring', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, + { form: 'a dynamic import of an unrelated node builtin', source: `void (await import('node:url')).pathToFileURL('x');` }, + { form: 'a dynamic import of a relative module', source: `const m = await import('./local.js');\nvoid m;` }, + { form: 'a type-only node:http import used only in types', source: `import type { Server } from 'node:http';\nlet s: Server | null = null;\nvoid s;` }, + ]; + for (const { form, source } of optbAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // Export confinement (unified classification) closes the static destructure-then-export + // residual and preserves type-only / ordinary exports. + const exportReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a statically destructured request re-exported', source: `import * as http from 'node:http';\nconst { request } = http;\nexport { request };` }, + { form: 'a statically destructured aliased client re-exported', source: `import * as http from 'node:http';\nconst { get: g } = http;\nexport { g };` }, + { form: 'a namespace binding re-exported', source: `import * as http from 'node:http';\nexport { http };` }, + { form: 'a named client import re-exported', source: `import { request } from 'node:http';\nexport { request };` }, + { form: 'an export-equals of a namespace binding', source: `import http = require('node:http');\nexport = http;` }, + ]; + for (const { form, source } of exportReject) { + it(`REJECTS export of ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(true); + }); + } + const exportAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an exported created server instance', source: `import http from 'node:http';\nexport const server = http.createServer(() => {});` }, + { form: 'a destructured createServer re-exported', source: `import * as http from 'node:http';\nconst { createServer } = http;\nexport { createServer };` }, + { form: 'an ordinary application export', source: `export const HOST = '127.0.0.1';` }, + { form: 'a relative re-export', source: `export { foo } from './x.js';` }, + { form: 'a type-only re-export', source: `import type { Server } from 'node:http';\nexport type { Server };` }, + ]; + for (const { form, source } of exportAllow) { + it(`ALLOWS export of ${form}`, () => { + const sf = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + expect(exportsHttpCapability(sf)).toBe(false); + }); + } +}); From 6bf1d647645351b16f981e178dde8082206ad8fd Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 14:19:06 +0200 Subject: [PATCH 07/35] test(cockpit): delegate binding identity to TypeScript --- tests/cockpit-host/purity.test.ts | 731 +++++++++++++----------------- 1 file changed, 312 insertions(+), 419 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 39954c4..dd59641 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -337,19 +337,6 @@ const unwrapExpr = (node: ts.Expression): ts.Expression => { return cur; }; -// Whether an initializer is a STATICALLY-RESOLVED runtime import of node:http — -// `import('node:http')` or `await import('node:http')` (parens/`as`/await unwrapped). -// A computed/dynamic specifier is not matched (it is already failed-closed by -// `hasUnverifiableDynamicImport`); only the exact allow-listed literal acquires the -// node:http namespace capability, so it gains the SAME identity as `import * as http`. -const isNodeHttpDynamicImport = (expr: ts.Expression): boolean => { - let cur = unwrapExpr(expr); - while (ts.isAwaitExpression(cur)) cur = unwrapExpr(cur.expression); - if (!ts.isCallExpression(cur) || cur.expression.kind !== ts.SyntaxKind.ImportKeyword) return false; - const arg: ts.Expression | undefined = cur.arguments[0]; - return arg !== undefined && ts.isStringLiteralLike(arg) && arg.text === 'node:http'; -}; - const isGlobalReceiver = (node: ts.Expression): boolean => { const n = unwrapExpr(node); return ts.isIdentifier(n) && GLOBAL_RECEIVER_NAMES.has(n.text); @@ -593,40 +580,6 @@ const servesAsAccessObject = (node: ts.Node): boolean => { return (ts.isPropertyAccessExpression(p) || ts.isElementAccessExpression(p)) && p.expression === cur; }; -// NET-LOCAL: whether an HTTP_NS identifier occurrence is a forbidden ESCAPE of the -// privileged node:http namespace — i.e. NOT one of the three permitted positions: -// (1) the access-object receiver of a member/element access (a permitted createServer -// access is then decided by the member rule); -// (2) the initializer of an object-binding-pattern destructuring (F2 decides members); -// (3) a type/non-runtime position — the LEFT of a type QualifiedName, a `typeof` type -// query, an import-type, or the operand of the runtime `typeof` operator. -// Intentionally local to NET; it does NOT change the shared `isValueReference` or RC/HA. -const isHttpNamespaceEscape = (id: ts.Identifier): boolean => { - if (servesAsAccessObject(id)) return false; - const par = id.parent as ts.Node | undefined; - if ( - par !== undefined && - ts.isVariableDeclaration(par) && - ts.isObjectBindingPattern(par.name) && - par.initializer === id - ) { - return false; - } - let cur: ts.Node = id; - let gp = cur.parent as ts.Node | undefined; - while (gp !== undefined && ts.isQualifiedName(gp)) { - cur = gp; - gp = cur.parent as ts.Node | undefined; - } - if ( - gp !== undefined && - (ts.isTypeQueryNode(gp) || ts.isTypeReferenceNode(gp) || ts.isImportTypeNode(gp) || ts.isTypeOfExpression(gp)) - ) { - return false; - } - return true; -}; - /** * RC — reject runtime code generation at its acquisition site (D3-CX-POLICY-RC v2). * @@ -902,417 +855,357 @@ const HTTP_SERVER_VALUE_MEMBERS: ReadonlySet = new Set(['createServer']) // COMPLETE importless-egress global surface — a bounded family, not an open blacklist. const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']); -interface HttpBindings { - // Local names bound to the node:http MODULE (`import http` / `import * as h` / - // `import http = require('node:http')`), used as `binding.request(...)`. - readonly namespaceOrDefault: ReadonlySet; - // Local names bound to a NON-`createServer` node:http named value (`import - // { request, get as g, ClientRequest, Agent }`), used bare as `request(...)` / - // `g(...)` / `new ClientRequest()` / `new Agent()`. A named `createServer` is - // deliberately NOT collected — it is the one legitimate value export. - readonly namedClient: ReadonlySet; -} - -// Discover, structurally, the local bindings this module introduces from -// `node:http`. Keyed on the exact specifier `node:http`, so a plain local object -// named `http` (no such import) yields no binding and is never treated as the -// network module. -const collectHttpBindings = (sourceFile: ts.SourceFile): HttpBindings => { - const NODE_HTTP = 'node:http'; - const namespaceOrDefault = new Set(); - const namedClient = new Set(); - const visit = (node: ts.Node): void => { - if ( - ts.isImportDeclaration(node) && - ts.isStringLiteral(node.moduleSpecifier) && - node.moduleSpecifier.text === NODE_HTTP && - node.importClause !== undefined - ) { - const clause = node.importClause; - // `import http from 'node:http'` — default binding. - if (clause.name !== undefined) namespaceOrDefault.add(clause.name.text); - const bindings = clause.namedBindings; - if (bindings !== undefined) { - if (ts.isNamespaceImport(bindings)) { - // `import * as http from 'node:http'`. - namespaceOrDefault.add(bindings.name.text); - } else { - // `import { createServer, request, Agent as A } from 'node:http'` — collect - // every named import EXCEPT `createServer` (the sole allowed value export), - // under its LOCAL name (`el.name`). The positive model treats any other - // node:http named value as a non-server capability. - for (const el of bindings.elements) { - const imported = (el.propertyName ?? el.name).text; - if (!HTTP_SERVER_VALUE_MEMBERS.has(imported)) namedClient.add(el.name.text); - } - } - } - } else if ( - ts.isImportEqualsDeclaration(node) && - ts.isExternalModuleReference(node.moduleReference) && - ts.isStringLiteral(node.moduleReference.expression) && - node.moduleReference.expression.text === NODE_HTTP - ) { - // `import http = require('node:http')`. - namespaceOrDefault.add(node.name.text); - } - ts.forEachChild(node, visit); +// A node:http runtime authority capability. `HTTP_NS` is the namespace; `HTTP_CLIENT` +// any non-createServer node:http value; `CREATE_SERVER` the one permitted capability. +type HttpCapability = 'HTTP_NS' | 'HTTP_CLIENT' | 'CREATE_SERVER' | 'NONE'; + +// Build a bounded, in-memory, single-file Program so the compiler BINDER supplies +// lexical binding identity (Option D). `noLib`+`noResolve`: no filesystem, no module +// resolution, no network, deterministic. The only file served is the analyzed source; +// node:http is never loaded — we read the import declaration's specifier TEXT, never its +// types — so binding identity, not module contents, is all this guard depends on. +const buildBinderProgram = (source: string): { readonly checker: ts.TypeChecker; readonly sourceFile: ts.SourceFile } => { + const fileName = 'module.ts'; + const parsed = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const host: ts.CompilerHost = { + getSourceFile: (name) => (name === fileName ? parsed : undefined), + getDefaultLibFileName: () => 'lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '/', + getDirectories: () => [], + getCanonicalFileName: (f) => f, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (f) => f === fileName, + readFile: (f) => (f === fileName ? source : undefined), }; - ts.forEachChild(sourceFile, visit); - return { namespaceOrDefault, namedClient }; + const program = ts.createProgram( + [fileName], + { noLib: true, noResolve: true, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.Latest }, + host, + ); + const bound = program.getSourceFile(fileName); + return { checker: program.getTypeChecker(), sourceFile: bound ?? parsed }; }; -// Design D — node:http capability EXPORT confinement. The privileged node:http -// namespace/capability may not cross the D3 module boundary: reject any re-export FROM -// node:http (`export * from 'node:http'`, `export { … } from 'node:http'`) and any export -// of a LOCAL binding whose lexical identity is HTTP_NS (`export { http }`, -// `export { http as h }`, `export default http`, `export = http`) — createServer included. -// Ordinary local exports and type-only exports are untouched. Inspects only THIS module's -// statements and local binding identity: no cross-module value-flow. -const exportsHttpCapability = (sourceFile: ts.SourceFile): boolean => { - // OPTION B unification: consume the SAME lexical binding classification the - // network-egress analysis uses (`buildModuleFrame` over `collectHttpBindings`), so the - // export boundary can never disagree with `usesOutboundNetwork`. A binding classified - // HTTP_NS or HTTP_CLIENT — a namespace, a named client import, OR a locally-destructured - // non-createServer member — may not cross the module boundary; createServer/LOCAL and - // type-only forms may. No second name inventory. - const NODE_HTTP = 'node:http'; - const frame = buildModuleFrame(sourceFile, collectHttpBindings(sourceFile)); - const carriesAuthority = (name: string): boolean => { - const kind = frame.get(name); - return kind === 'HTTP_NS' || kind === 'HTTP_CLIENT'; - }; - for (const s of sourceFile.statements) { - // (a) a RUNTIME re-export from node:http (`export * from` / `export { … } from`). - if ( - ts.isExportDeclaration(s) && - !s.isTypeOnly && - s.moduleSpecifier !== undefined && - ts.isStringLiteral(s.moduleSpecifier) && - s.moduleSpecifier.text === NODE_HTTP - ) { - return true; - } - // (b) a local re-export of a binding carrying node:http authority. Type-only skipped. - if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.moduleSpecifier === undefined && s.exportClause !== undefined && ts.isNamedExports(s.exportClause)) { - for (const el of s.exportClause.elements) { - if (!el.isTypeOnly && carriesAuthority((el.propertyName ?? el.name).text)) return true; - } - } - // (c) `export default ` / `export = `. - if (ts.isExportAssignment(s) && ts.isIdentifier(s.expression) && carriesAuthority(s.expression.text)) return true; - // (d) an EXPORTED declaration whose bound name(s) carry node:http authority. - if (ts.isVariableStatement(s) && ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true) { - for (const decl of s.declarationList.declarations) { - const names: string[] = []; - eachBoundName(decl.name, (n) => names.push(n)); - if (names.some((n) => carriesAuthority(n))) return true; - } - } +const binderUnwrap = (node: ts.Expression): ts.Expression => { + let cur: ts.Expression = node; + while ( + ts.isParenthesizedExpression(cur) || + ts.isAsExpression(cur) || + ts.isSatisfiesExpression(cur) || + ts.isNonNullExpression(cur) || + ts.isTypeAssertionExpression(cur) || + ts.isAwaitExpression(cur) + ) { + cur = cur.expression; } - return false; + return cur; }; -// A lexical binding kind for the identity-sensitive detection below. `HTTP_NS` and -// `HTTP_CLIENT` mark the module-level node:http import bindings (namespace/default, -// and any named node:http value that is not `createServer` respectively); `LOCAL` -// marks any OTHER declaration -// (parameter, const/let/var, function/class, catch, or unrelated import) that -// SHADOWS an outer binding of the same name. Capability identity is therefore the -// binding VISIBLE at an occurrence, never mere identifier text (NET-S1/NET-S2). -type BindingKind = 'HTTP_NS' | 'HTTP_CLIENT' | 'LOCAL'; - -// Collect every identifier a binding name introduces — a plain identifier or a -// (possibly nested) destructuring pattern — into `sink`. Bounded by the finite -// binding-pattern tree; performs no value resolution. -const eachBoundName = (name: ts.BindingName, sink: (text: string) => void): void => { - if (ts.isIdentifier(name)) { - sink(name.text); - return; - } - for (const element of name.elements) { - if (ts.isBindingElement(element)) eachBoundName(element.name, sink); - } +const binderMemberName = (node: ts.PropertyAccessExpression | ts.ElementAccessExpression): string | null => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + const arg = node.argumentExpression; + return ts.isStringLiteralLike(arg) ? arg.text : null; }; -// The names a single statement declares DIRECTLY in its own scope — const/let/var -// declarations and function/class declaration names — with no descent into nested -// blocks or functions (those get their own frames). The required matrices exercise -// only const/let/params, so `var`'s function-hoisting is a stated bounded gap that -// cannot make a covered case wrong. -const declaredByStatement = (statement: ts.Statement, sink: (text: string) => void): void => { - if (ts.isVariableStatement(statement)) { - for (const decl of statement.declarationList.declarations) eachBoundName(decl.name, sink); - } else if ( - (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && - statement.name !== undefined - ) { - sink(statement.name.text); +// The specifier text of the import a declaration belongs to (or null when not an import). +const declarationImportSpecifier = (decl: ts.Declaration): string | null => { + let p: ts.Node | undefined = decl; + while (p !== undefined) { + if (ts.isImportDeclaration(p)) return ts.isStringLiteral(p.moduleSpecifier) ? p.moduleSpecifier.text : null; + if (ts.isImportEqualsDeclaration(p)) { + const ref = p.moduleReference; + return ts.isExternalModuleReference(ref) && ts.isStringLiteral(ref.expression) ? ref.expression.text : null; + } + p = p.parent as ts.Node | undefined; } + return null; }; -// F1/F2 node:http capability PROPAGATION, reusing binding identity (never text): -// F1 `const http = (await) import('node:http')` -> `http` is HTTP_NS, exactly as a -// namespace import would be. -// F2 `const { request, get: g, createServer: cs } = ` -> each destructured -// member preserves the namespace capability: `createServer` (the one allowed -// server value) stays LOCAL, every other member is HTTP_CLIENT (outbound). The -// RHS must LEXICALLY resolve to HTTP_NS, so `const { request } = someLocalObject` -// is untouched. Bounded: one binding-pattern level, static keys only; deeper -// nesting and genuinely computed keys are unsupported (documented) gaps. -const propagateHttpCapability = ( - decl: ts.VariableDeclaration, - lookup: (name: string) => BindingKind | undefined, - sink: (name: string, kind: BindingKind) => void, -): void => { - if (decl.initializer === undefined) return; - if (ts.isIdentifier(decl.name)) { - if (isNodeHttpDynamicImport(decl.initializer)) sink(decl.name.text, 'HTTP_NS'); - return; +// Whether a symbol is DIRECTLY a static node:http namespace binding (`import * as http` +// / `import http` / `import http = require('node:http')`). One-hop, non-recursive — the +// only acquisition relation `HTTP_CLIENT` destructuring is allowed to consult. +const isDirectNodeHttpNamespace = (symbol: ts.Symbol | undefined): boolean => { + if (symbol === undefined || symbol.declarations === undefined) return false; + return symbol.declarations.some( + (d) => + (ts.isNamespaceImport(d) || (ts.isImportClause(d) && d.name !== undefined) || ts.isImportEqualsDeclaration(d)) && + declarationImportSpecifier(d) === 'node:http', + ); +}; + +// Classify a single DECLARATION. Bounded: a node:http import, or a destructuring whose +// initializer DIRECTLY resolves to the node:http namespace (one hop, no alias chains). +const classifyHttpDeclaration = (decl: ts.Declaration, checker: ts.TypeChecker): HttpCapability => { + if (ts.isNamespaceImport(decl)) return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + if (ts.isImportClause(decl) && decl.name !== undefined) { + return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + } + if (ts.isImportEqualsDeclaration(decl)) return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + if (ts.isImportSpecifier(decl)) { + if (declarationImportSpecifier(decl) !== 'node:http') return 'NONE'; + const imported = (decl.propertyName ?? decl.name).text; + return HTTP_SERVER_VALUE_MEMBERS.has(imported) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; } - if (ts.isObjectBindingPattern(decl.name)) { - // The RHS is the node:http namespace when it is a lexically-HTTP_NS identifier - // OR the dynamic import itself (`const { request } = await import('node:http')`, - // the direct F1+F2 composition). - const rhs = unwrapExpr(decl.initializer); - const rhsIsHttpNs = - isNodeHttpDynamicImport(decl.initializer) || (ts.isIdentifier(rhs) && lookup(rhs.text) === 'HTTP_NS'); - if (!rhsIsHttpNs) return; - for (const el of decl.name.elements) { - if (!ts.isIdentifier(el.name)) continue; // nested pattern: unsupported - const key = el.propertyName; - const member = - key === undefined - ? el.name.text - : ts.isIdentifier(key) - ? key.text - : ts.isStringLiteralLike(key) + if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent) && ts.isVariableDeclaration(decl.parent.parent)) { + const initializer = decl.parent.parent.initializer; + if (initializer !== undefined) { + const rhs = binderUnwrap(initializer); + if (ts.isIdentifier(rhs) && isDirectNodeHttpNamespace(checker.getSymbolAtLocation(rhs))) { + const key = decl.propertyName; + const member = + key === undefined + ? ts.isIdentifier(decl.name) + ? decl.name.text + : null + : ts.isIdentifier(key) ? key.text - : ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression) - ? key.expression.text - : null; - if (member === null) continue; // genuinely computed key: unsupported - sink(el.name.text, HTTP_SERVER_VALUE_MEMBERS.has(member) ? 'LOCAL' : 'HTTP_CLIENT'); + : ts.isStringLiteralLike(key) + ? key.text + : ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression) + ? key.expression.text + : null; + return member !== null && HTTP_SERVER_VALUE_MEMBERS.has(member) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; + } } } + return 'NONE'; }; -// The module (top-level) lexical frame: node:http import bindings tagged with their -// capability kind, every other top-level binding tagged LOCAL. This is the outermost -// frame for the whole file, so a genuine module-level `const fetch` shadows the -// global `fetch` module-wide (NET-S2), while `globalThis.fetch` stays a member call. -const buildModuleFrame = (sourceFile: ts.SourceFile, http: HttpBindings): Map => { - const frame = new Map(); - for (const statement of sourceFile.statements) { - declaredByStatement(statement, (text) => frame.set(text, 'LOCAL')); - if (ts.isImportDeclaration(statement) && statement.importClause !== undefined) { - const clause = statement.importClause; - if (clause.name !== undefined) frame.set(clause.name.text, 'LOCAL'); - const bindings = clause.namedBindings; - if (bindings !== undefined) { - if (ts.isNamespaceImport(bindings)) { - frame.set(bindings.name.text, 'LOCAL'); - } else { - for (const el of bindings.elements) frame.set(el.name.text, 'LOCAL'); - } - } - } else if (ts.isImportEqualsDeclaration(statement)) { - frame.set(statement.name.text, 'LOCAL'); +// Classify a SYMBOL by its declaration(s) — the compiler binder resolved the symbol, so +// this is nearest-visible-binding identity (shadowing/restoration/scope all included). +const classifyHttpSymbol = (symbol: ts.Symbol | undefined, checker: ts.TypeChecker): HttpCapability => { + if (symbol === undefined || symbol.declarations === undefined) return 'NONE'; + for (const decl of symbol.declarations) { + const cap = classifyHttpDeclaration(decl, checker); + if (cap !== 'NONE') return cap; + } + return 'NONE'; +}; + +// Classify an EXPRESSION directly: an identifier (via its symbol) or a member access off +// an HTTP_NS receiver (createServer vs other). Bounded by member-access nesting (finite); +// no binding-element recursion, so no cycles. +const classifyHttpExpression = (expr: ts.Expression, checker: ts.TypeChecker): HttpCapability => { + const e = binderUnwrap(expr); + if (ts.isIdentifier(e)) return classifyHttpSymbol(checker.getSymbolAtLocation(e), checker); + if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) { + if (classifyHttpExpression(e.expression, checker) === 'HTTP_NS') { + const m = binderMemberName(e); + return m !== null && HTTP_SERVER_VALUE_MEMBERS.has(m) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; } } - // Capability override: the node:http import bindings win their specific kind over - // the generic LOCAL tag applied above. - for (const name of http.namespaceOrDefault) frame.set(name, 'HTTP_NS'); - for (const name of http.namedClient) frame.set(name, 'HTTP_CLIENT'); - // F1/F2: dynamic-import acquisition and destructuring off HTTP_NS, in source order so - // a capability acquired earlier is visible to a later destructuring in the same scope. - for (const statement of sourceFile.statements) { - if (ts.isVariableStatement(statement)) { - for (const decl of statement.declarationList.declarations) { - propagateHttpCapability(decl, (n) => frame.get(n), (name, kind) => frame.set(name, kind)); - } + return 'NONE'; +}; + +// Whether an identifier is a value read (not a declaration/type/member/key position). +const isBinderValueReference = (id: ts.Identifier): boolean => { + const p = id.parent as ts.Node | undefined; + if (p === undefined) return true; + if (ts.isQualifiedName(p) && p.right === id) return false; + if (ts.isTypeReferenceNode(p)) return false; + if (ts.isPropertyAccessExpression(p) && p.name === id) return false; + if (ts.isBindingElement(p) && (p.name === id || p.propertyName === id)) return false; + if (ts.isVariableDeclaration(p) && p.name === id) return false; + if (ts.isParameter(p) && p.name === id) return false; + if (ts.isPropertyAssignment(p) && p.name === id) return false; + if ( + (ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) || ts.isClassDeclaration(p) || ts.isMethodDeclaration(p)) && + p.name === id + ) { + return false; + } + if (ts.isImportSpecifier(p) || ts.isNamespaceImport(p) || ts.isImportClause(p) || ts.isExportSpecifier(p)) return false; + return true; +}; + +// The permitted positions for an HTTP_NS occurrence (else it is a forbidden escape): a +// member-access receiver, an object-binding-pattern destructuring initializer, or a +// type/non-runtime position (through paren/as/await wrappers). +const isHttpNsSafePosition = (node: ts.Node): boolean => { + let cur: ts.Node = node; + for (;;) { + const parent = cur.parent as ts.Node | undefined; + if ( + parent !== undefined && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isAwaitExpression(parent)) + ) { + cur = parent; + continue; } + break; + } + const p = cur.parent as ts.Node | undefined; + if (p === undefined) return false; + if ((ts.isPropertyAccessExpression(p) || ts.isElementAccessExpression(p)) && p.expression === cur) return true; + if (ts.isVariableDeclaration(p) && ts.isObjectBindingPattern(p.name) && p.initializer === cur) return true; + if ( + ts.isTypeQueryNode(p) || + ts.isTypeReferenceNode(p) || + ts.isQualifiedName(p) || + ts.isTypeOfExpression(p) || + ts.isImportTypeNode(p) + ) { + return true; } - return frame; + return false; }; -const usesOutboundNetwork = (source: string): boolean => { - const sourceFile = ts.createSourceFile( - 'module.ts', - source, - ts.ScriptTarget.Latest, - /* setParentNodes */ true, - ts.ScriptKind.TS, +// A receiver is the REAL global (globalThis/window/self/global) only when the binder +// resolves it to NO local binding in this file (an intrinsic globalThis has a symbol but +// no local declaration; a param/const shadow does). Identifier text is not identity. +const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const recv = binderUnwrap(expr); + if (!ts.isIdentifier(recv) || !GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; + const symbol = checker.getSymbolAtLocation(recv); + if (symbol === undefined || symbol.declarations === undefined) return true; + const shadowedLocally = symbol.declarations.some( + (d) => + d.getSourceFile() === sourceFile && + (ts.isParameter(d) || + ts.isVariableDeclaration(d) || + ts.isBindingElement(d) || + ts.isFunctionDeclaration(d) || + ts.isClassDeclaration(d) || + ts.isImportClause(d) || + ts.isImportSpecifier(d) || + ts.isNamespaceImport(d)), ); - const constMap = collectStringConsts(sourceFile); - const http = collectHttpBindings(sourceFile); - - // Lexical environment: a stack of frames, innermost last. `resolve` returns the - // kind of the NEAREST visible binding of a name, or undefined when the name is - // FREE at that occurrence (an unshadowed global such as `fetch`). Frames are - // pushed on scope entry and popped on scope exit, so sibling scopes are - // independent and an inner shadow vanishes once its scope closes. - const scopes: Map[] = [buildModuleFrame(sourceFile, http)]; - const resolve = (name: string): BindingKind | undefined => { - for (let i = scopes.length - 1; i >= 0; i--) { - const kind = scopes[i]?.get(name); - if (kind !== undefined) return kind; - } - return undefined; - }; + return !shadowedLocally; +}; - // A receiver is the REAL global object only when its identifier is a global name - // (globalThis/window/self/global) AND no lexical binding shadows it at this - // occurrence — identifier text is not binding identity. So - // `function f(globalThis) { globalThis.fetch('local'); }` resolves the receiver to - // the LOCAL parameter and is NOT the global, while an unshadowed `globalThis.fetch` - // (FREE receiver) stays a real global member. Uses the same scope stack as `resolve` - // rather than the RC-shared text-only `isGlobalReceiver`, so this stays local to NET. - const isLexicalGlobalReceiver = (expr: ts.Expression): boolean => { - const recv = unwrapExpr(expr); - return ts.isIdentifier(recv) && GLOBAL_RECEIVER_NAMES.has(recv.text) && resolve(recv.text) === undefined; - }; +// Whether a call is a runtime dynamic `import('node:http')` (Option B: prohibited outright). +const isDynamicNodeHttpImport = (node: ts.Node): boolean => { + if (!ts.isCallExpression(node) || node.expression.kind !== ts.SyntaxKind.ImportKeyword) return false; + const specifier = node.arguments[0]; + return specifier !== undefined && ts.isStringLiteralLike(specifier) && specifier.text === 'node:http'; +}; - // The frame a scope-introducing node contributes, or null when it is not one. - // Function-likes contribute their parameters; blocks their direct lexical - // declarations; for-headers their loop variables; catch clauses their variable. - const frameFor = (node: ts.Node): Map | null => { - if ( - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - ts.isMethodDeclaration(node) || - ts.isConstructorDeclaration(node) || - ts.isGetAccessorDeclaration(node) || - ts.isSetAccessorDeclaration(node) - ) { - const frame = new Map(); - for (const param of node.parameters) eachBoundName(param.name, (t) => frame.set(t, 'LOCAL')); - // A NAMED function EXPRESSION binds its own name inside its body only (unlike a - // function DECLARATION, whose name lives in the enclosing scope and is already - // recorded there by `declaredByStatement`). Recording the self-binding in this - // frame lets `const helper = function request() { return request(); }` resolve - // the inner recursive call to the LOCAL self-binding, shadowing an imported - // `request`; the frame is popped on scope exit, so it never leaks to siblings or - // to the enclosing scope, where the import must still be rejected. - if (ts.isFunctionExpression(node) && node.name !== undefined) { - frame.set(node.name.text, 'LOCAL'); +/** + * NET — reject outbound network egress, decided by TypeScript BINDER identity + * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, + * restoration, for/switch/catch scope, parameter scope, named function-expression + * self-binding, and computed-name evaluation order — is delegated to the compiler binder + * via `checker.getSymbolAtLocation`, so this guard NEVER re-implements ECMAScript/ + * TypeScript scoping. The Program is in-memory, single-file, `noLib`+`noResolve`: no + * filesystem, no module resolution, no network. Policy is the positive model: runtime + * `import('node:http')` is prohibited; the node:http namespace may be used only to obtain + * `createServer` (member access or `createServer`-only destructuring) or in type + * positions; any other node:http value (`HTTP_CLIENT`) may not be referenced; the free + * network globals `fetch`/`WebSocket` are rejected when truly unbound. + */ +const usesOutboundNetwork = (source: string): boolean => { + const { checker, sourceFile } = buildBinderProgram(source); + let found = false; + const visit = (node: ts.Node): void => { + // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. + if (isDynamicNodeHttpImport(node)) found = true; + // (1) member/element access: an HTTP_NS receiver is permitted ONLY for `createServer`; + // a network global (`fetch`/`WebSocket`) off a FREE global receiver is rejected. + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + if (classifyHttpExpression(node.expression, checker) === 'HTTP_NS') { + const member = binderMemberName(node); + if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; } - return frame; - } - if (ts.isBlock(node) || ts.isModuleBlock(node)) { - const frame = new Map(); - for (const statement of node.statements) declaredByStatement(statement, (t) => frame.set(t, 'LOCAL')); - // F1/F2 capability propagation within this block, resolving a destructuring RHS - // against this frame first, then the enclosing scopes (outer HTTP_NS imports). - for (const statement of node.statements) { - if (ts.isVariableStatement(statement)) { - for (const decl of statement.declarationList.declarations) { - propagateHttpCapability(decl, (n) => frame.get(n) ?? resolve(n), (name, kind) => frame.set(name, kind)); - } - } + const globalMember = binderMemberName(node); + if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { + found = true; } - return frame; } - if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) { - const frame = new Map(); - const init = node.initializer; - if (init !== undefined && ts.isVariableDeclarationList(init)) { - for (const decl of init.declarations) eachBoundName(decl.name, (t) => frame.set(t, 'LOCAL')); + // (2) an identifier value read: HTTP_CLIENT is forbidden; HTTP_NS must sit in a safe + // position (else escape); a FREE network global (`fetch`/`WebSocket`) is rejected. + if (ts.isIdentifier(node) && isBinderValueReference(node)) { + const symbol = checker.getSymbolAtLocation(node); + if (symbol !== undefined) { + const cap = classifyHttpSymbol(symbol, checker); + if (cap === 'HTTP_CLIENT') found = true; + if (cap === 'HTTP_NS' && !isHttpNsSafePosition(node)) found = true; + } else if (NETWORK_GLOBAL_NAMES.has(node.text)) { + found = true; } - return frame; } - if (ts.isCatchClause(node)) { - const frame = new Map(); - if (node.variableDeclaration !== undefined) { - eachBoundName(node.variableDeclaration.name, (t) => frame.set(t, 'LOCAL')); + // (3) destructuring a network global off a FREE global receiver: `const { fetch } = globalThis`. + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent) && ts.isVariableDeclaration(node.parent.parent)) { + const key = node.propertyName ?? node.name; + const name = ts.isIdentifier(key) ? key.text : ts.isStringLiteralLike(key) ? key.text : null; + const initializer = node.parent.parent.initializer; + if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && initializer !== undefined && isFreeGlobalReceiver(initializer, checker, sourceFile)) { + found = true; } - return frame; } - return null; + ts.forEachChild(node, visit); }; + ts.forEachChild(sourceFile, visit); + return found; +}; - let found = false; - const visit = (node: ts.Node): void => { - const frame = frameFor(node); - if (frame !== null) scopes.push(frame); - - // OPTION B — a runtime dynamic `import('node:http')` is prohibited OUTRIGHT: the D3 - // host acquires node:http statically only. Rooted at the import expression itself, so - // it is rejected in EVERY context (receiver, argument, return, array/object/spread, - // conditional/logical, variable initializer, for-header, default export, wrappers) with - // no per-context rule. Non-node:http dynamic imports are untouched. - if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { - const specifier = node.arguments[0]; - if (specifier !== undefined && ts.isStringLiteralLike(specifier) && specifier.text === 'node:http') { - found = true; +// Design D / Option-B export confinement, consuming the SAME binder-backed classification +// as `usesOutboundNetwork`: a binding classified HTTP_NS or HTTP_CLIENT (namespace, named +// client import, or a locally-destructured non-createServer member) may not cross the +// module boundary; createServer/LOCAL and type-only forms may. One source of truth. +const exportsHttpCapability = (inputSourceFile: ts.SourceFile): boolean => { + const { checker, sourceFile } = buildBinderProgram(inputSourceFile.text); + const carriesAuthority = (symbol: ts.Symbol | undefined): boolean => { + const cap = classifyHttpSymbol(symbol, checker); + return cap === 'HTTP_NS' || cap === 'HTTP_CLIENT'; + }; + for (const statement of sourceFile.statements) { + // (a) a RUNTIME re-export from node:http (`export * from` / a non-type-only specifier). + if ( + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === 'node:http' + ) { + if (statement.exportClause === undefined) return true; + if (ts.isNamedExports(statement.exportClause)) { + for (const el of statement.exportClause.elements) { + if (!el.isTypeOnly) return true; + } } } - - if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { - const member = memberNameOf(node, constMap); - const recv = unwrapExpr(node.expression); - const recvIsHttpNs = - (ts.isIdentifier(recv) && resolve(recv.text) === 'HTTP_NS') || isNodeHttpDynamicImport(recv); - // (d/3) an HTTP_NS receiver is permitted ONLY for a statically-PROVEN `createServer` - // member; every other known member (`.request`/`.get`/`.ClientRequest`/`.Agent`/…) - // AND any indeterminate/computed member (`http[dynamicKey]`, member === null) is - // rejected fail-closed. `createServer` is the sole allowed value member; type-only - // `http.ServerResponse` etc. are QualifiedName, not member accesses. - const provenCreateServer = member !== null && HTTP_SERVER_VALUE_MEMBERS.has(member); - if (recvIsHttpNs && !provenCreateServer) found = true; - if (member !== null) { - // (a) `.fetch` / statically-keyed `['fetch']` — only when the - // receiver identifier is a global name that is FREE here (no local binding - // shadows it). A shadowing local (param/const/…) makes it an ordinary - // object, so `function f(globalThis) { globalThis.fetch(...) }` is allowed. - if (NETWORK_GLOBAL_NAMES.has(member) && isLexicalGlobalReceiver(node.expression)) found = true; + // (b) a local re-export of a binding carrying node:http authority. Type-only skipped. + if ( + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ) { + for (const el of statement.exportClause.elements) { + if (!el.isTypeOnly && carriesAuthority(checker.getExportSpecifierLocalTargetSymbol(el))) return true; } } - // (b) destructuring `fetch` off a global receiver, i.e. `const { fetch: f } = - // globalThis` — but only when that receiver is the FREE global (a shadowing - // local `globalThis`/… makes it an ordinary object, and `const { fetch } = - // someLocalConfig` is untouched). - if (ts.isBindingElement(node)) { - const name = bindingPropertyName(node); - const decl = node.parent.parent; - const offGlobalReceiver = - ts.isVariableDeclaration(decl) && - decl.initializer !== undefined && - isLexicalGlobalReceiver(decl.initializer); - if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && offGlobalReceiver) found = true; + // (c) `export default ` / `export = `. + if (ts.isExportAssignment(statement)) { + const cap = classifyHttpExpression(statement.expression, checker); + if (cap === 'HTTP_NS' || cap === 'HTTP_CLIENT') return true; } - if (ts.isIdentifier(node) && isValueReference(node)) { - // The `key` in a destructuring `const { key: local } = …` is a binding - // PROPERTY name, not an expression read (handled receiver-scoped by rule (b)), - // so it must not be mistaken for a bare reference to the global. - const isBindingPropertyKey = ts.isBindingElement(node.parent) && node.parent.propertyName === node; - if (!isBindingPropertyKey) { - const kind = resolve(node.text); - // (c) a bare network-global reference (`fetch`, `WebSocket`) that is FREE here - // — no lexical binding of the name is visible — so it is the global. A - // local shadow resolves to LOCAL and is allowed; `const f = fetch` is still - // caught at `fetch`. - if (NETWORK_GLOBAL_NAMES.has(node.text) && kind === undefined) found = true; - // (e) a bare reference that lexically resolves to a NON-`createServer` node:http - // named import (`request(...)`, `req(...)`, `new Agent()`); a shadowing - // local resolves LOCAL. - if (kind === 'HTTP_CLIENT') found = true; - // (f) DESIGN A non-escape: an HTTP_NS value reference is permitted ONLY as an - // access-object receiver (rule d/3), a destructuring initializer (F2), or a - // NET-local type/non-runtime position; EVERY other runtime reference is a - // forbidden ESCAPE (`const h = http`, `foo(http)`, `return http`, `[http]`, - // `{ v: http }`, `{ ...http }`, `export default http`) — rejected AT the name, - // so no alias/value-flow tracking is needed. - if (kind === 'HTTP_NS' && isHttpNamespaceEscape(node)) found = true; + // (d) an EXPORTED declaration whose bound name(s) carry node:http authority. + if (ts.isVariableStatement(statement) && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true) { + const names: ts.Identifier[] = []; + const collect = (binding: ts.BindingName): void => { + if (ts.isIdentifier(binding)) { + names.push(binding); + } else { + for (const el of binding.elements) { + if (ts.isBindingElement(el)) collect(el.name); + } + } + }; + for (const decl of statement.declarationList.declarations) collect(decl.name); + for (const nm of names) { + if (carriesAuthority(checker.getSymbolAtLocation(nm))) return true; } } - - ts.forEachChild(node, visit); - if (frame !== null) scopes.pop(); - }; - ts.forEachChild(sourceFile, visit); - return found; + } + return false; }; describe('D3 host has no mutation, subprocess, secret, or Git capability', () => { @@ -4357,7 +4250,6 @@ describe('D3 host may not export node:http capability across the module boundary { form: 'exporting a namespace import binding', source: `import * as http from 'node:http';\nexport { http };` }, { form: 'exporting an aliased namespace binding', source: `import * as http from 'node:http';\nexport { http as h };` }, { form: 'default-exporting a namespace binding', source: `import * as http from 'node:http';\nexport default http;` }, - { form: 'exporting a dynamic-import namespace binding', source: `const http = await import('node:http');\nexport { http };` }, { form: 'export-equals of a namespace binding', source: `import http = require('node:http');\nexport = http;` }, ]; for (const { form, source } of exportReject) { @@ -4420,8 +4312,6 @@ describe('D3 host closes direct dynamic-import receivers and runtime capability { form: 'a named get import re-exported', source: `import { get } from 'node:http';\nexport { get };` }, { form: 'a named ClientRequest import re-exported', source: `import { ClientRequest } from 'node:http';\nexport { ClientRequest };` }, { form: 'a named Agent import re-exported', source: `import { Agent } from 'node:http';\nexport { Agent };` }, - { form: 'an exported dynamic-import namespace declaration', source: `export const http = await import('node:http');` }, - { form: 'an exported destructured dynamic-import client member', source: `export const { request } = await import('node:http');` }, { form: 'an exported destructured namespace client member', source: `import * as http from 'node:http';\nexport const { request } = http;` }, ]; for (const { form, source } of exportReject) { @@ -4456,6 +4346,9 @@ describe('D3 host closes direct dynamic-import receivers and runtime capability // --------------------------------------------------------------------------- describe('D3 host prohibits runtime dynamic import of node:http (D3-CX-POLICY-NET-OPTB)', () => { const optbReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an exported destructured dynamic-import client member', source: `export const { request } = await import('node:http');` }, + { form: 'an exported dynamic-import namespace declaration', source: `export const http = await import('node:http');` }, + { form: 'a namespace binding acquired from a dynamic import then exported', source: `const http = await import('node:http');\nexport { http };` }, { form: 'a bare awaited dynamic import', source: `void (await import('node:http'));` }, { form: 'a parenthesized awaited dynamic import', source: `void ((await import('node:http')));` }, { form: 'a dynamic import passed as an argument', source: `declare function send(x: unknown): void;\nsend(await import('node:http'));` }, From 8ca0a1fdc5959584cbe3f2f13aab51170e7b6db9 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 19:39:50 +0200 Subject: [PATCH 08/35] test(cockpit): bound socket source policy Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ag7ZrUfkkKnbh6YxLU1fLQ --- tests/cockpit-host/purity.test.ts | 330 ++++++++++++++++++++++++++++-- 1 file changed, 318 insertions(+), 12 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index dd59641..03a0661 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1059,23 +1059,24 @@ const isHttpNsSafePosition = (node: ts.Node): boolean => { // A receiver is the REAL global (globalThis/window/self/global) only when the binder // resolves it to NO local binding in this file (an intrinsic globalThis has a symbol but // no local declaration; a param/const shadow does). Identifier text is not identity. +// +// Local-value shadow rule (no declaration-kind whitelist): the receiver is free/global +// only when its resolved symbol carries NO declaration belonging to the analyzed source +// file. Any genuine local VALUE binding of the name — const/let/var, parameter, binding +// element, function, class, enum, a namespace with a runtime value, import-equals, an +// import binding, and any other runtime declaration — is a shadow, so the receiver is an +// ordinary object, not the global. The value/type distinction is delegated to the binder, +// not re-listed here: at a value-position receiver `checker.getSymbolAtLocation` resolves +// with VALUE meaning, so a name whose only local declaration is type-only +// (`interface global` / `type global`) does not resolve to that declaration — the symbol +// is undefined (or carries no in-file declaration), and the receiver is correctly still +// the free global. Restoration/nesting/sibling scope are the binder's job as before. const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { const recv = binderUnwrap(expr); if (!ts.isIdentifier(recv) || !GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; const symbol = checker.getSymbolAtLocation(recv); if (symbol === undefined || symbol.declarations === undefined) return true; - const shadowedLocally = symbol.declarations.some( - (d) => - d.getSourceFile() === sourceFile && - (ts.isParameter(d) || - ts.isVariableDeclaration(d) || - ts.isBindingElement(d) || - ts.isFunctionDeclaration(d) || - ts.isClassDeclaration(d) || - ts.isImportClause(d) || - ts.isImportSpecifier(d) || - ts.isNamespaceImport(d)), - ); + const shadowedLocally = symbol.declarations.some((d) => d.getSourceFile() === sourceFile); return !shadowedLocally; }; @@ -1086,6 +1087,131 @@ const isDynamicNodeHttpImport = (node: ts.Node): boolean => { return specifier !== undefined && ts.isStringLiteralLike(specifier) && specifier.text === 'node:http'; }; +// SOCK — reject acquisition of the inbound SERVER SOCKET capability (D3-CX-POLICY-NET-SOCK). +// The permitted `createServer` path yields a listening server whose request/response objects +// and connection-family events expose the underlying duplex socket — a transitive OUTBOUND +// capability (`req.socket.connect(...)`) the createServer allowance is not meant to grant. +// This is the FINAL BOUNDED D3 source policy (commander decision, frozen): the strongest +// finite policy that stays compatible with the actual host. It is NOT a taint/alias/type/ +// whole-program engine and does NOT claim literal no-egress — three purely syntactic rules: +// +// RULE A — GLOBAL static socket/connection NAME ban, regardless of receiver identity. A +// property named `socket`/`connection` acquired by a statically identifiable name is +// rejected: dotted `x.socket` (optional chaining included), static-computed +// `x['socket']`/`` x[`connection`] ``, and object-destructuring `{ socket }` / +// `{ socket: s }` / `{ connection }` in any binding pattern (variable, parameter, +// nested, callback). Receiver identity is deliberately irrelevant — an unrelated +// `camera.socket` is an accepted, intentional policy false positive; real host/cockpit +// source uses neither name. +// +// RULE A2 — for the request/response PARAMETERS of a function literal passed DIRECTLY to a +// permitted static `createServer` call (binder identity — these are the sole direct +// entry of IncomingMessage/ServerResponse into user code), an element access whose key +// is not a static string FAILS CLOSED (`req[key]`, `req['sock'+'et']`, `req[c?…:…]`). +// This closes computed recovery on the direct handler param without touching legitimate +// host indexing elsewhere (`array[index]`, `text[character]`, `object[key]` are NOT on a +// createServer handler param, so they are unaffected). No const-folding is used. +// +// RULE B — BLANKET event-registration ban. Any call whose callee member is a registrar +// name (`on`/`once`/`addListener`/`prependListener`/`prependOnceListener`, read from a +// dotted or static-key access) is rejected regardless of receiver, event name, or handler +// shape. Real D3 host/cockpit source registers NO events, so this eliminates every +// socket-delivering event route — `request`, `connection`, `upgrade`, `connect`, +// `clientError`, `dropRequest`, and any future one — with no event-name list to maintain +// and no wrapper/receiver inference. The accepted, bounded false positive is that an +// unrelated synthetic `emitter.on('ready', …)` is also rejected. +// +// HONEST BOUNDARY (frozen, not a defect): an aliased/cross-function request combined with a +// runtime-computed key — `const r = request; r[runtimeKey]` where `runtimeKey` becomes +// `'socket'` at runtime — is NOT closed; closing it would require alias propagation / type +// resolution / whole-program flow, deliberately excluded here. It belongs to a future +// runtime-isolation enforcement boundary, not this source policy. +const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connection']); +const SOCKET_EVENT_REGISTRARS: ReadonlySet = new Set([ + 'on', + 'once', + 'addListener', + 'prependListener', + 'prependOnceListener', +]); + +// The static key named by an element-access argument or a binding-element key, or null. +const staticKeyText = (key: ts.Node | undefined): string | null => { + if (key === undefined) return null; + if (ts.isIdentifier(key)) return key.text; + if (ts.isStringLiteralLike(key)) return key.text; + if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text; + return null; +}; + +const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const isCreateServerCall = (node: ts.Node): boolean => + ts.isCallExpression(node) && classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; + + // Pass 1 (RULE A2 support) — collect the createServer request/response parameter symbols. + // Direct identifier params only; a destructured param `({ socket })` is a RULE A binding + // pattern, rejected in pass 2 like any other. + const reqResSymbols = new Set(); + const collect = (node: ts.Node): void => { + if (isCreateServerCall(node)) { + for (const arg of (node as ts.CallExpression).arguments) { + const handler = binderUnwrap(arg); + if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) { + for (const param of handler.parameters) { + if (ts.isIdentifier(param.name)) { + const symbol = checker.getSymbolAtLocation(param.name); + if (symbol !== undefined) reqResSymbols.add(symbol); + } + } + } + } + } + ts.forEachChild(node, collect); + }; + ts.forEachChild(sourceFile, collect); + + const receiverIsReqRes = (expr: ts.Expression): boolean => { + const e = binderUnwrap(expr); + if (!ts.isIdentifier(e)) return false; + const symbol = checker.getSymbolAtLocation(e); + return symbol !== undefined && reqResSymbols.has(symbol); + }; + + let found = false; + const visit = (node: ts.Node): void => { + // RULE A (a) — GLOBAL dotted `.socket`/`.connection` (optional chaining included). + if (ts.isPropertyAccessExpression(node) && SOCKET_CAPABILITY_NAMES.has(node.name.text)) found = true; + // RULE A (b) — GLOBAL static-computed `['socket']`/`['connection']`; else RULE A2 fails + // closed on an indeterminate computed key when the receiver is a createServer param. + if (ts.isElementAccessExpression(node)) { + const arg = node.argumentExpression; + if (ts.isStringLiteralLike(arg)) { + if (SOCKET_CAPABILITY_NAMES.has(arg.text)) found = true; + } else if (receiverIsReqRes(node.expression)) { + found = true; + } + } + // RULE A (c) — GLOBAL `{ socket }` / `{ connection }` destructuring in any object binding + // pattern (variable, parameter, nested, callback), including `{ socket: s }`. + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + const name = staticKeyText(node.propertyName ?? node.name); + if (name !== null && SOCKET_CAPABILITY_NAMES.has(name)) found = true; + } + // RULE B — BLANKET event-registration ban: any registrar member call, any receiver, any + // event name (dotted `.on` or static-key `['on']`). + if ( + ts.isCallExpression(node) && + (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression)) + ) { + const registrar = binderMemberName(node.expression); + if (registrar !== null && SOCKET_EVENT_REGISTRARS.has(registrar)) found = true; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return found; +}; + /** * NET — reject outbound network egress, decided by TypeScript BINDER identity * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, @@ -1141,6 +1267,10 @@ const usesOutboundNetwork = (source: string): boolean => { ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); + // Inbound server-socket acquisition (SOCK) is decided over the SAME single-file binder + // program — one source of truth, no reparse — and is an additional rejection reason: the + // permitted createServer path may not be used to acquire the underlying socket capability. + if (acquiresInboundServerSocket(checker, sourceFile)) found = true; return found; }; @@ -4412,3 +4542,179 @@ describe('D3 host prohibits runtime dynamic import of node:http (D3-CX-POLICY-NE }); } }); + +// --------------------------------------------------------------------------- +// SOCK final bounded D3 socket policy (D3-CX-POLICY-NET-SOCK). Three finite, purely +// syntactic rules, no taint/alias/type/whole-program: RULE A — a GLOBAL static ban on +// acquiring a property named `socket`/`connection` (dotted, optional, static-computed, or +// destructured) regardless of receiver; RULE A2 — on the request/response parameters of a +// function literal passed directly to a permitted `createServer`, an indeterminate computed +// element access fails closed; RULE B — a BLANKET ban on every event registrar call +// (`on`/`once`/`addListener`/`prependListener`/`prependOnceListener`), any receiver, any +// event name. Accepted, intentional false positives: an unrelated `camera.socket` and an +// unrelated `emitter.on('ready', …)` are rejected because real host/cockpit source uses +// neither. The alias + runtime-computed residual (`const r = request; r[runtimeKey]`) is the +// frozen honest boundary, deliberately not closed here. +// --------------------------------------------------------------------------- +describe('D3 host enforces the final bounded socket-capability source policy (D3-CX-POLICY-NET-SOCK)', () => { + it('accepts every real host source (no host source acquires the inbound socket)', () => { + for (const { file, text } of hostSources()) { + expect(usesOutboundNetwork(text), `${file} acquires the inbound server socket`).toBe(false); + } + }); + + // The reported F1 transitive-egress reproduction: the permitted createServer path used to + // reach `req.socket` and call `.connect(...)` outbound. Rejected now at the acquisition. + it('rejects the reported req.socket transitive-outbound reproduction', () => { + expect( + usesOutboundNetwork( + `import http from 'node:http';\n` + + `http.createServer((req: http.IncomingMessage) => {\n` + + ` req.socket.destroy();\n` + + ` req.socket.connect(80, 'example.com');\n` + + `});`, + ), + ).toBe(true); + }); + + const H = `import http from 'node:http';\n`; + const handler = (body: string): string => + H + `http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {\n${body}\n});`; + + // --- RULE A: GLOBAL static socket/connection NAME ban (any receiver) — MUST REJECT --- + const ruleAReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an unrelated camera.socket (accepted global false positive)', source: `const camera = { socket: 1 };\nvoid camera.socket;` }, + { form: 'an unrelated thing.connection (accepted global false positive)', source: `const thing = { connection: 'local' };\nvoid thing.connection;` }, + { form: 'a plain local named request with a .socket member', source: `const request = { socket: 1 };\nvoid request.socket;` }, + { form: 'a direct req.socket member', source: handler(`req.socket.destroy();`) }, + { form: 'a direct req.connection member', source: handler(`req.connection.destroy();`) }, + { form: 'a res.socket member', source: handler(`void res.socket;`) }, + { form: 'an optional req?.socket access', source: handler(`void req?.socket;`) }, + { form: 'an optional obj?.connection access', source: `const obj: { connection?: unknown } = {};\nvoid obj?.connection;` }, + { form: 'an alias const s = req.socket', source: handler(`const s = req.socket;\nvoid s;`) }, + { form: "a static-computed req['socket'] access", source: handler(`void req['socket'];`) }, + { form: 'a template-computed req[`socket`] access', source: handler('void req[`socket`];') }, + { form: "a static-computed obj['connection'] on any receiver", source: `const obj: Record = {};\nvoid obj['connection'];` }, + { form: 'the aliased-then-.socket escape (closed by the global name ban)', source: handler(`const r = req;\nvoid r.socket;`) }, + { form: 'a cross-function x.socket (closed by the global name ban)', source: H + `function h(x: { socket: unknown }): unknown { return x.socket; }\nhttp.createServer((req: http.IncomingMessage) => {\n void h(req);\n});` }, + ]; + + // --- RULE A: destructuring `{ socket }` / `{ connection }` (any binding pattern) — REJECT --- + const destructureReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a { socket } destructuring off req', source: handler(`const { socket } = req;\nvoid socket;`) }, + { form: 'a { connection } destructuring off an unrelated object', source: `const obj = { connection: 1 };\nconst { connection } = obj;\nvoid connection;` }, + { form: 'an aliased { socket: s } destructuring off res', source: handler(`const { socket: s } = res;\nvoid s;`) }, + { form: 'a { connection: c } destructuring off req', source: handler(`const { connection: c } = req;\nvoid c;`) }, + { form: 'an inline { socket } destructuring parameter', source: H + `http.createServer(({ socket }: http.IncomingMessage) => {\n void socket;\n});` }, + { form: 'a second-parameter { socket } destructuring', source: H + `http.createServer((_req: http.IncomingMessage, { socket }: http.ServerResponse) => {\n void socket;\n});` }, + { form: 'a function parameter { connection } destructuring', source: `function f({ connection }: { connection: unknown }): void {\n void connection;\n}\nvoid f;` }, + ]; + + // --- RULE A2: createServer handler param indeterminate computed access — FAIL-CLOSED --- + const ruleA2Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an indeterminate req[key] on the handler param', source: handler(`const key = req.url ?? '';\nvoid req[key];`) }, + { form: 'an indeterminate res[key] on the handler param', source: handler(`const key = req.url ?? '';\nvoid res[key];`) }, + { form: "a concatenated req['sock' + 'et'] on the handler param", source: handler(`void req['sock' + 'et'];`) }, + { form: 'a conditional req[c ? "socket" : "method"] on the handler param', source: handler(`const c = req.method === 'GET';\nvoid req[c ? 'socket' : 'method'];`) }, + ]; + + // --- RULE B: BLANKET event-registration ban (any registrar, any receiver, any event) --- + const registrarNames = ['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']; + const anyEventNames = ['request', 'connection', 'dropRequest', 'ready']; + const wrapperHead = + H + `function makeServer(): http.Server {\n return http.createServer(() => {});\n}\nconst server = makeServer();\n`; + const eventReject: { readonly form: string; readonly source: string }[] = []; + for (const reg of registrarNames) { + for (const evt of anyEventNames) { + eventReject.push({ + form: `a wrapper-return server.${reg}('${evt}', …)`, + source: wrapperHead + `server.${reg}('${evt}', () => {});`, + }); + } + } + const eventRejectSpecial: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a 'request' event on a direct const server binding", source: H + `const server = http.createServer(() => {});\nserver.on('request', () => {});` }, + { form: "a 'connection' event on the direct createServer return chain", source: H + `http.createServer(() => {}).on('connection', () => {});` }, + { form: "a static-key registrar server['on']('request', …)", source: wrapperHead + `server['on']('request', () => {});` }, + { form: "an unrelated declared emitter registering 'ready' (accepted false positive)", source: `declare const ee: { on(event: string, cb: () => void): void };\nee.on('ready', () => {});` }, + { form: "a synthetic LocalEmitter registering 'anything' (accepted false positive)", source: `class LocalEmitter {\n addListener(_event: string, _cb: () => void): void {}\n}\nconst emitter = new LocalEmitter();\nemitter.addListener('anything', () => {});` }, + ]; + + for (const { form, source } of [ + ...ruleAReject, + ...destructureReject, + ...ruleA2Reject, + ...eventReject, + ...eventRejectSpecial, + ]) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- MUST ALLOW: the legitimate request/response surface and unrelated non-socket shapes --- + const sockAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the ordinary request.method read', source: handler(`const m = req.method ?? '';\nvoid m;`) }, + { form: 'the ordinary request.url read', source: handler(`const u = req.url ?? '';\nvoid u;`) }, + { form: 'the ordinary response surface', source: handler(`res.statusCode = 200;\nres.setHeader('X', 'Y');\nres.end('ok');`) }, + { form: "a static request['method'] access on the handler param", source: handler(`void req['method'];`) }, + { form: 'a template request[`url`] access on the handler param', source: handler('void req[`url`];') }, + { form: 'an empty createServer handler', source: H + `http.createServer(() => {});` }, + { form: 'a listen call on a const server binding', source: H + `const server = http.createServer(() => {});\nserver.listen(4317, '127.0.0.1');` }, + { form: 'unrelated array[index] indexing', source: `const array: readonly number[] = [];\nconst index = 0;\nvoid array[index];` }, + { form: 'unrelated text[character] indexing', source: `const text = 'abc';\nconst character = 1;\nvoid text[character];` }, + { form: 'unrelated object[key] indexing', source: `const object: Record = {};\nconst key = 'a';\nvoid object[key];` }, + { form: 'a non-socket ordinary member on an unrelated object', source: `const obj = { value: 1 };\nvoid obj.value;` }, + ]; + for (const { form, source } of sockAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + +// --------------------------------------------------------------------------- +// NET free-global receiver is a LOCAL-VALUE-SHADOW question, not a declaration-kind list +// (D3-CX-POLICY-NET-SHADOW-LOCAL). A global-receiver name (globalThis/window/self/global) +// is the real free global only when the binder resolves it to NO declaration in this file. +// ANY genuine local runtime VALUE binding of the name shadows the global — including enum, +// a value namespace, and import-equals, which a prior declaration-kind whitelist missed and +// wrongly flagged as egress. The value/type split is the binder's: at a value-position +// receiver a type-only-only name (`interface`/`type`) does not resolve, so it stays the +// free global and its network member is still rejected. +// --------------------------------------------------------------------------- +describe('D3 host free-global receiver is a local-value-shadow decision (D3-CX-POLICY-NET-SHADOW-LOCAL)', () => { + // A genuine local runtime VALUE binding of the receiver name is a shadow → ALLOWED. + const localShadowAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an enum global shadow', source: `enum global { fetch }\nvoid global.fetch;` }, + { form: 'a value-namespace global shadow', source: `namespace global {\n export const fetch = (x: string) => x;\n}\nvoid global.fetch('a');` }, + { form: 'an import-equals global shadow', source: `import global = require('x');\nvoid global.fetch;` }, + { form: 'a const global shadow', source: `const global = { fetch: (x: string) => x };\nvoid global.fetch('a');` }, + { form: 'a binding-element global shadow', source: `const { global } = { global: { fetch: (x: string) => x } };\nvoid global.fetch('a');` }, + { form: 'a class global shadow', source: `class global {\n static fetch = (x: string) => x;\n}\nvoid global.fetch('a');` }, + { form: 'a function global shadow (WebSocket member)', source: `function global() {}\nvoid (global as unknown as { WebSocket: unknown }).WebSocket;` }, + { form: 'an enum window shadow', source: `enum window { fetch }\nvoid window.fetch;` }, + { form: 'a value-namespace self shadow', source: `namespace self {\n export const WebSocket = class {};\n}\nvoid new self.WebSocket();` }, + { form: 'a parameter global shadow', source: `function f(global: { fetch: (v: string) => string }) {\n return global.fetch('local');\n}` }, + ]; + for (const { form, source } of localShadowAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // A name whose ONLY local declaration is type-only is NOT a runtime shadow: the receiver + // stays the free global and the network member is REJECTED. Real free globals too. + const stillFreeReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an interface-only global (still the free global)', source: `interface global { fetch: (v: string) => string }\nglobal.fetch('https://evil/');` }, + { form: 'a type-alias-only global (still the free global)', source: `type global = { fetch: (v: string) => string };\nglobal.fetch('https://evil/');` }, + { form: 'a real free globalThis.fetch', source: `globalThis.fetch('https://evil/');` }, + { form: 'a real free window.fetch', source: `window.fetch('https://evil/');` }, + { form: 'a real free self.WebSocket', source: `new self.WebSocket('wss://evil/');` }, + ]; + for (const { form, source } of stillFreeReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } +}); From 6bff9a4081a37fce89ccd6a77d21c60d209c0310 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 20:26:01 +0200 Subject: [PATCH 09/35] test(cockpit): close codex network purity gaps Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ag7ZrUfkkKnbh6YxLU1fLQ --- tests/cockpit-host/purity.test.ts | 194 +++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 17 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 03a0661..b97a2d5 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1056,28 +1056,71 @@ const isHttpNsSafePosition = (node: ts.Node): boolean => { return false; }; -// A receiver is the REAL global (globalThis/window/self/global) only when the binder -// resolves it to NO local binding in this file (an intrinsic globalThis has a symbol but -// no local declaration; a param/const shadow does). Identifier text is not identity. +// A declaration EMITS a runtime value binding — and so can shadow a runtime global — only +// when it is neither ambient (`declare …`, which emits nothing) nor a type-only form +// (interface / type alias / type-only import). This is the minimal runtime-emission +// distinction, read from the AST and modifier flags rather than restored as a +// declaration-kind whitelist: it separates a real local shadow (`const fetch = …`, +// `function fetch`, `class`, `enum`, a value namespace, `import x = require(...)`, a runtime +// import binding) from a declaration-only binding (`declare const fetch`, a type-only import) +// that leaves the runtime global reachable at the call site. +// Whether a node sits in an ambient (`declare …`) context — itself or any enclosing +// declaration carries the `declare` modifier (covers `declare const`, `declare function`, +// and a binding nested in `declare global` / `declare namespace`). Public API only. +const isInAmbientContext = (node: ts.Node): boolean => { + let n: ts.Node | undefined = node; + while (n !== undefined) { + if (ts.canHaveModifiers(n)) { + const mods = ts.getModifiers(n); + if (mods !== undefined && mods.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) return true; + } + n = n.parent as ts.Node | undefined; + } + return false; +}; + +// Whether an import binding is type-only — the whole clause (`import type { fetch }`, whose +// clause carries the `type` phase modifier) or the inline specifier form (`import { type fetch }`). +const isTypeOnlyImportClause = (clause: ts.ImportClause): boolean => clause.phaseModifier === ts.SyntaxKind.TypeKeyword; + +const declarationEmitsRuntimeValue = (d: ts.Declaration): boolean => { + if (ts.isInterfaceDeclaration(d) || ts.isTypeAliasDeclaration(d)) return false; + if (isInAmbientContext(d)) return false; + if (ts.isImportClause(d) && isTypeOnlyImportClause(d)) return false; + if (ts.isImportSpecifier(d)) { + if (d.isTypeOnly) return false; + const clause = d.parent.parent as ts.Node | undefined; + if (clause !== undefined && ts.isImportClause(clause) && isTypeOnlyImportClause(clause)) return false; + } + return true; +}; + +// Whether the binder-resolved symbol has, in the analyzed file, at least one declaration that +// emits a runtime value — i.e. a genuine RUNTIME shadow of a same-named global. A symbol whose +// only in-file declarations are ambient/type-only is NOT a runtime shadow. +const hasLocalRuntimeShadow = (symbol: ts.Symbol, sourceFile: ts.SourceFile): boolean => + symbol.declarations !== undefined && + symbol.declarations.some((d) => d.getSourceFile() === sourceFile && declarationEmitsRuntimeValue(d)); + +// A receiver is the REAL global (globalThis/window/self/global) only when the binder resolves +// it to NO local RUNTIME binding in this file (an intrinsic globalThis has a symbol but no +// local declaration; a param/const shadow does). Identifier text is not identity. // -// Local-value shadow rule (no declaration-kind whitelist): the receiver is free/global -// only when its resolved symbol carries NO declaration belonging to the analyzed source -// file. Any genuine local VALUE binding of the name — const/let/var, parameter, binding -// element, function, class, enum, a namespace with a runtime value, import-equals, an -// import binding, and any other runtime declaration — is a shadow, so the receiver is an -// ordinary object, not the global. The value/type distinction is delegated to the binder, -// not re-listed here: at a value-position receiver `checker.getSymbolAtLocation` resolves -// with VALUE meaning, so a name whose only local declaration is type-only -// (`interface global` / `type global`) does not resolve to that declaration — the symbol -// is undefined (or carries no in-file declaration), and the receiver is correctly still -// the free global. Restoration/nesting/sibling scope are the binder's job as before. +// Local runtime-shadow rule (no declaration-kind whitelist): the receiver is free/global only +// when its resolved symbol carries no in-file declaration that EMITS A RUNTIME VALUE. Any +// genuine runtime binding of the name — const/let/var, parameter, binding element, function, +// class, enum, a namespace with a runtime value, import-equals, a runtime import — is a shadow, +// so the receiver is an ordinary object. Two kinds of declaration are correctly NOT shadows: +// a type-only name (`interface global` / `type global`) does not even resolve at a value +// position (the binder returns no value symbol), and an ambient `declare const global` resolves +// but emits nothing at runtime, so the real global is still reached — both leave the receiver +// free. Restoration/nesting/sibling scope are the binder's job as before. const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { const recv = binderUnwrap(expr); if (!ts.isIdentifier(recv) || !GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; const symbol = checker.getSymbolAtLocation(recv); - if (symbol === undefined || symbol.declarations === undefined) return true; - const shadowedLocally = symbol.declarations.some((d) => d.getSourceFile() === sourceFile); - return !shadowedLocally; + if (symbol === undefined) return true; + return !hasLocalRuntimeShadow(symbol, sourceFile); }; // Whether a call is a runtime dynamic `import('node:http')` (Option B: prohibited outright). @@ -1197,6 +1240,28 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou const name = staticKeyText(node.propertyName ?? node.name); if (name !== null && SOCKET_CAPABILITY_NAMES.has(name)) found = true; } + // RULE A2 (destructuring) — an INDETERMINATE computed binding key destructured DIRECTLY + // from a createServer request/response handler parameter fails closed (bound to the + // req/res initializer symbol identity, not globally). A statically resolvable key is + // unaffected here: a `socket`/`connection` name is already rejected by RULE A (c), a + // harmless name (`['method']`) is allowed. `const { ['sock'+'et']: s } = req` and + // `const { [key]: s } = req` are rejected; `const { [key]: s } = unrelated` is not. + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer !== undefined && + receiverIsReqRes(node.initializer) + ) { + for (const el of node.name.elements) { + if ( + el.propertyName !== undefined && + ts.isComputedPropertyName(el.propertyName) && + staticKeyText(el.propertyName) === null + ) { + found = true; + } + } + } // RULE B — BLANKET event-registration ban: any registrar member call, any receiver, any // event name (dotted `.on` or static-key `['on']`). if ( @@ -1251,6 +1316,10 @@ const usesOutboundNetwork = (source: string): boolean => { const cap = classifyHttpSymbol(symbol, checker); if (cap === 'HTTP_CLIENT') found = true; if (cap === 'HTTP_NS' && !isHttpNsSafePosition(node)) found = true; + // A bare network global (`fetch`/`WebSocket`) whose only in-file declarations emit no + // runtime value (an ambient `declare const fetch`, a type-only import) still reaches + // the runtime global — reject it. A real local shadow (const/function/class/…) does not. + if (NETWORK_GLOBAL_NAMES.has(node.text) && !hasLocalRuntimeShadow(symbol, sourceFile)) found = true; } else if (NETWORK_GLOBAL_NAMES.has(node.text)) { found = true; } @@ -1294,6 +1363,11 @@ const exportsHttpCapability = (inputSourceFile: ts.SourceFile): boolean => { statement.moduleSpecifier.text === 'node:http' ) { if (statement.exportClause === undefined) return true; + // A runtime namespace re-export `export * as http from 'node:http'` binds the whole + // node:http namespace under a name another host file can import and call + // (`http.request(...)`). The `!statement.isTypeOnly` guard above already excludes the + // type-only `export type * as http from 'node:http'`, which emits no runtime authority. + if (ts.isNamespaceExport(statement.exportClause)) return true; if (ts.isNamedExports(statement.exportClause)) { for (const el of statement.exportClause.elements) { if (!el.isTypeOnly) return true; @@ -4718,3 +4792,89 @@ describe('D3 host free-global receiver is a local-value-shadow decision (D3-CX-P }); } }); + +// --------------------------------------------------------------------------- +// Exact-head Codex findings (D3-CX-CODEX). F1: an ambient `declare` (or type-only) binding +// emits no runtime value, so it does NOT shadow a runtime network global — the real global is +// still reached and must be rejected, while a genuine runtime shadow stays allowed. F2: a +// computed object-binding key that does not statically resolve, destructured directly from a +// createServer request/response handler parameter, fails closed. F3: a runtime namespace +// re-export of node:http (`export * as http`) carries authority and is rejected, while the +// type-only form is preserved. +// --------------------------------------------------------------------------- +describe('D3 host rejects ambient/non-emitting shadows of runtime network globals (D3-CX-CODEX-F1)', () => { + // REJECT — a declaration-only binding leaves the runtime global reachable. + const ambientReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ambient declare const fetch used bare', source: `declare const fetch: (url: string) => Promise;\nvoid fetch('https://example.com');` }, + { form: 'an ambient declare const WebSocket constructed', source: `declare const WebSocket: new (url: string) => unknown;\nvoid new WebSocket('wss://example.com/');` }, + { form: 'an ambient declare function fetch used bare', source: `declare function fetch(url: string): Promise;\nvoid fetch('https://example.com');` }, + { form: 'a type-only import of fetch used at a value position', source: `import type { fetch } from './x.js';\nvoid fetch('https://example.com');` }, + { form: 'an ambient declare const global receiver', source: `declare const global: { fetch: (v: string) => void };\nglobal.fetch('https://example.com');` }, + ]; + for (const { form, source } of ambientReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ALLOW — a genuine runtime binding of the name is a real shadow. + const runtimeShadowAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a const runtime shadow of fetch', source: `const fetch = (u: string): string => u;\nvoid fetch('local');` }, + { form: 'a function runtime shadow of fetch', source: `function fetch(): void {}\nvoid fetch();` }, + { form: 'a class runtime shadow of WebSocket', source: `class WebSocket {}\nvoid new WebSocket();` }, + { form: 'an enum global runtime shadow', source: `enum global { fetch }\nvoid global.fetch;` }, + { form: 'a value-namespace global runtime shadow', source: `namespace global {\n export const fetch = (x: string): string => x;\n}\nvoid global.fetch('a');` }, + { form: 'an import-equals value runtime shadow', source: `import global = require('x');\nvoid global.fetch;` }, + ]; + for (const { form, source } of runtimeShadowAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + +describe('D3 host fails closed on computed req/res destructuring keys (D3-CX-CODEX-F2)', () => { + const H = `import http from 'node:http';\n`; + const handler = (body: string): string => + H + `http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {\n${body}\n});`; + // REJECT — an indeterminate computed binding key off a handler parameter. + const computedReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: "the reported const { ['sock' + 'et']: s } = req reproducer", source: handler(`const { ['sock' + 'et']: s } = req;\nvoid s;`) }, + { form: 'a const { [key]: x } = req with an indeterminate key', source: handler(`const key = req.url ?? '';\nconst { [key]: x } = req;\nvoid x;`) }, + { form: 'a conditional computed key off req', source: handler(`const c = req.method === 'GET';\nconst { [c ? 'socket' : 'method']: x } = req;\nvoid x;`) }, + { form: 'an indeterminate computed key off res', source: handler(`const key = req.url ?? '';\nconst { [key]: x } = res;\nvoid x;`) }, + ]; + for (const { form, source } of computedReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + // ALLOW — a static harmless key off a handler param, and unrelated computed destructuring. + const computedAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a static harmless const { ['method']: m } = req", source: handler(`const { ['method']: m } = req;\nvoid m;`) }, + { form: 'an unrelated computed destructuring (not a handler param)', source: `const obj: Record = {};\nconst key = 'x';\nconst { [key]: v } = obj;\nvoid v;` }, + { form: 'an ordinary static request field destructuring', source: handler(`const { method, url } = req;\nvoid method;\nvoid url;`) }, + ]; + for (const { form, source } of computedAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + +describe('D3 host rejects namespace re-export of node:http authority (D3-CX-CODEX-F3)', () => { + const sf = (source: string): ts.SourceFile => + ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + it('REJECTS a runtime `export * as http from node:http`', () => { + expect(exportsHttpCapability(sf(`export * as http from 'node:http';`))).toBe(true); + }); + it('REJECTS a runtime `export * as anyName from node:http`', () => { + expect(exportsHttpCapability(sf(`export * as h from 'node:http';`))).toBe(true); + }); + it('PRESERVES a type-only `export type * as http from node:http`', () => { + expect(exportsHttpCapability(sf(`export type * as http from 'node:http';`))).toBe(false); + }); + it('PRESERVES an unrelated namespace re-export', () => { + expect(exportsHttpCapability(sf(`export * as local from './local.js';`))).toBe(false); + }); +}); From b5b07a38be7c809cd01a059a638c1917e4e972cf Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 21:03:43 +0200 Subject: [PATCH 10/35] test(cockpit): cover socket assignment destructuring Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ag7ZrUfkkKnbh6YxLU1fLQ --- tests/cockpit-host/purity.test.ts | 84 +++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index b97a2d5..db0221b 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1262,6 +1262,35 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } } } + // RULE A2 (assignment) — an object DESTRUCTURING ASSIGNMENT `({ socket: s } = req)` reads + // the property off the req/res param exactly like a declaration destructuring, but the + // target is an ObjectLiteralExpression (PropertyAssignment / ShorthandPropertyAssignment), + // not a binding pattern, so RULE A (c) / RULE A2 above do not see it. This is bound to the + // req/res initializer symbol identity (NOT global — an unrelated `({ socket: x } = obj)` + // stays allowed): a `socket`/`connection` key is rejected, an indeterminate computed key + // fails closed, and a static harmless key (`method`/`url`) is allowed. + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + receiverIsReqRes(node.right) + ) { + const target = binderUnwrap(node.left); + if (ts.isObjectLiteralExpression(target)) { + for (const prop of target.properties) { + if (ts.isShorthandPropertyAssignment(prop)) { + if (SOCKET_CAPABILITY_NAMES.has(prop.name.text)) found = true; + } else if (ts.isPropertyAssignment(prop)) { + if (ts.isComputedPropertyName(prop.name)) { + const key = staticKeyText(prop.name); + if (key === null || SOCKET_CAPABILITY_NAMES.has(key)) found = true; + } else { + const key = staticKeyText(prop.name); + if (key !== null && SOCKET_CAPABILITY_NAMES.has(key)) found = true; + } + } + } + } + } // RULE B — BLANKET event-registration ban: any registrar member call, any receiver, any // event name (dotted `.on` or static-key `['on']`). if ( @@ -4878,3 +4907,58 @@ describe('D3 host rejects namespace re-export of node:http authority (D3-CX-CODE expect(exportsHttpCapability(sf(`export * as local from './local.js';`))).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Codex P1: object DESTRUCTURING ASSIGNMENT socket acquisition (D3-CX-CODEX-ASSIGN). The +// permitted createServer path can extract the inbound socket through an assignment-target +// object pattern — `({ socket: s } = req)` — whose AST is a BinaryExpression over an +// ObjectLiteralExpression, not the BindingElement/VariableDeclaration forms RULE A(c)/A2 +// recognize. RULE A2 (assignment) closes it, scoped to the req/res initializer symbol identity: +// a socket/connection key is rejected, an indeterminate computed key fails closed, a static +// harmless key and any key off an unrelated object stay allowed (no global broadening). +// --------------------------------------------------------------------------- +describe('D3 host inspects destructuring assignments for socket acquisition (D3-CX-CODEX-ASSIGN)', () => { + const H = `import http from 'node:http';\n`; + const handler = (body: string): string => + H + `http.createServer((req: any, res: any): void => {\n${body}\n});`; + + it('rejects the reported ({ socket: s } = req) assignment reproducer', () => { + expect( + usesOutboundNetwork( + handler(`let s: any;\n({ socket: s } = req);\ns.destroy();\nsetTimeout(() => s.connect(80, 'example.com'), 50);`), + ), + ).toBe(true); + }); + + const assignReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a ({ socket: s } = req) assignment', source: handler(`let s: any;\n({ socket: s } = req);\nvoid s;`) }, + { form: 'a ({ connection: c } = req) assignment', source: handler(`let c: any;\n({ connection: c } = req);\nvoid c;`) }, + { form: 'a ({ socket: s } = res) assignment', source: handler(`let s: any;\n({ socket: s } = res);\nvoid s;`) }, + { form: 'a ({ connection: c } = res) assignment', source: handler(`let c: any;\n({ connection: c } = res);\nvoid c;`) }, + { form: 'a shorthand ({ socket } = req) assignment', source: handler(`let socket: any;\n({ socket } = req);\nvoid socket;`) }, + { form: "a static-computed ({ ['socket']: s } = req)", source: handler(`let s: any;\n({ ['socket']: s } = req);\nvoid s;`) }, + { form: 'a template-computed ({ [`connection`]: c } = req)', source: handler('let c: any;\n({ [`connection`]: c } = req);\nvoid c;') }, + { form: 'an indeterminate ({ [key]: x } = req)', source: handler(`let x: any;\nconst key = req.url ?? '';\n({ [key]: x } = req);\nvoid x;`) }, + { form: "a concatenated ({ ['sock' + 'et']: x } = req)", source: handler(`let x: any;\n({ ['sock' + 'et']: x } = req);\nvoid x;`) }, + { form: 'a conditional ({ [c ? "socket" : "method"]: x } = res)', source: handler(`let x: any;\nconst c = req.method === 'GET';\n({ [c ? 'socket' : 'method']: x } = res);\nvoid x;`) }, + ]; + for (const { form, source } of assignReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const assignAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a static harmless ({ method: m } = req)', source: handler(`let m: any;\n({ method: m } = req);\nvoid m;`) }, + { form: 'a static harmless ({ url: u } = req)', source: handler(`let u: any;\n({ url: u } = req);\nvoid u;`) }, + { form: "a static-computed harmless ({ ['method']: m } = req)", source: handler(`let m: any;\n({ ['method']: m } = req);\nvoid m;`) }, + { form: 'an unrelated ({ [key]: x } = obj)', source: `const obj: Record = {};\nconst key = 'x';\nlet x: unknown;\n({ [key]: x } = obj);\nvoid x;` }, + { form: 'an unrelated ({ value: x } = obj)', source: `const obj = { value: 1 };\nlet x: unknown;\n({ value: x } = obj);\nvoid x;` }, + { form: 'an unrelated ({ socket: localSocket } = unrelatedObject)', source: `const unrelatedObject = { socket: 1 };\nlet localSocket: unknown;\n({ socket: localSocket } = unrelatedObject);\nvoid localSocket;` }, + ]; + for (const { form, source } of assignAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); From 8e32c7fbb1eb15121cc35c48d6b778998f03516b Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 22:06:23 +0200 Subject: [PATCH 11/35] test(cockpit): close computed global network gap Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ag7ZrUfkkKnbh6YxLU1fLQ --- tests/cockpit-host/purity.test.ts | 74 ++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index db0221b..fdb139a 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1321,6 +1321,12 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou */ const usesOutboundNetwork = (source: string): boolean => { const { checker, sourceFile } = buildBinderProgram(source); + // Statically-resolvable string constants for the free-global network-member check (F1): + // reuses the existing `collectStringConsts` / `memberNameOf` machinery so a computed member + // key that folds to a literal (`'fe' + 'tch'`, a `const key = 'fetch'`) is classified like a + // direct literal. A key that does not statically resolve stays `null` here and remains + // rejected fail-closed by the independent runtime-code guard — this never weakens that. + const constMap = collectStringConsts(sourceFile); let found = false; const visit = (node: ts.Node): void => { // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. @@ -1332,7 +1338,11 @@ const usesOutboundNetwork = (source: string): boolean => { const member = binderMemberName(node); if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; } - const globalMember = binderMemberName(node); + // F1 — a free-global network member (`fetch`/`WebSocket`) resolved through the existing + // static-string machinery: a direct literal, a `+`-fold (`'fe' + 'tch'`), or a unique + // immutable `const key = 'fetch'`. A genuinely indeterminate key resolves to `null` + // and is not flagged here (the runtime-code guard rejects it fail-closed). + const globalMember = memberNameOf(node, constMap); if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { found = true; } @@ -4962,3 +4972,65 @@ describe('D3 host inspects destructuring assignments for socket acquisition (D3- }); } }); + +// --------------------------------------------------------------------------- +// Codex P1: statically-COMPUTED free-global network members (D3-CX-CODEX-F1-COMPUTED). The +// free-global network-member check resolved the member with the literal-only `binderMemberName`, +// so a key that folds to a literal (`globalThis['fe' + 'tch']`, `const key = 'fetch'; globalThis[key]`) +// escaped `usesOutboundNetwork` while never being a runtime-code-generation capability either. +// The check now resolves the member through the existing `collectStringConsts` / `memberNameOf` +// static-string machinery (no new evaluator), so the whole NETWORK_GLOBAL_NAMES family is caught +// for direct, `+`-folded, and unique-immutable-const keys. A genuinely indeterminate key is not +// resolved here and remains rejected fail-closed by the runtime-code guard. +// --------------------------------------------------------------------------- +describe('D3 host rejects statically-computed free-global network members (D3-CX-CODEX-F1-COMPUTED)', () => { + const rejectComputed: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a dotted globalThis.fetch', source: `globalThis.fetch('https://example.com/');` }, + { form: 'an optional globalThis?.fetch', source: `globalThis?.fetch('https://example.com/');` }, + { form: "a literal globalThis['fetch']", source: `globalThis['fetch']('https://example.com/');` }, + { form: 'a template globalThis[`fetch`]', source: 'globalThis[`fetch`]("https://example.com/");' }, + { form: "a concatenated globalThis['fe' + 'tch']", source: `globalThis['fe' + 'tch']('https://example.com/');` }, + { form: 'a const-key globalThis[key]', source: `const key = 'fetch';\nglobalThis[key]('https://example.com/');` }, + { form: "a const-prefix globalThis[prefix + 'tch']", source: `const prefix = 'fe';\nglobalThis[prefix + 'tch']('https://example.com/');` }, + { form: 'a bare new WebSocket', source: `void new WebSocket('wss://example.com/');` }, + { form: 'a dotted new globalThis.WebSocket', source: `void new globalThis.WebSocket('wss://example.com/');` }, + { form: "a literal new globalThis['WebSocket']", source: `void new globalThis['WebSocket']('wss://example.com/');` }, + { form: "a concatenated new globalThis['Web' + 'Socket']", source: `void new globalThis['Web' + 'Socket']('wss://example.com/');` }, + { form: 'a const-key new globalThis[w]', source: `const w = 'WebSocket';\nvoid new globalThis[w]('wss://example.com/');` }, + { form: "a window['fe' + 'tch'] receiver", source: `window['fe' + 'tch']('https://example.com/');` }, + { form: "a self['We' + 'bSocket'] receiver", source: `void new self['We' + 'bSocket']('wss://example.com/');` }, + ]; + for (const { form, source } of rejectComputed) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // A genuinely indeterminate free-global member key is NOT resolved by the network branch (it + // stays out of scope for this static check) but REMAINS rejected fail-closed by the runtime- + // code guard — the overall purity enforcement still rejects it. This proves no weakening. + const indeterminate: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a runtime-key globalThis[k] call', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, + { form: 'a runtime-key new globalThis[k]', source: `declare const k: string;\nvoid new globalThis[k]('wss://example.com/');` }, + ]; + for (const { form, source } of indeterminate) { + it(`keeps ${form} rejected by the runtime-code guard`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // ALLOW / PRESERVE — a computed member that does not fold to a protected network global, and a + // local runtime shadow of the receiver, stay allowed. + const allowComputed: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a non-network globalThis['ordinaryLocalMember']", source: `void globalThis['ordinaryLocalMember'];` }, + { form: "a non-network concatenated globalThis['con' + 'sole']", source: `void globalThis['con' + 'sole'];` }, + { form: 'a receiver-shadowed globalThis with a computed member', source: `function f(globalThis: { fetch: (v: string) => string }): string {\n return globalThis['fe' + 'tch']('local');\n}\nvoid f;` }, + { form: 'a computed member off an unrelated local object', source: `const rt = { fetch: (v: string) => v };\nvoid rt['fe' + 'tch']('local');` }, + ]; + for (const { form, source } of allowComputed) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); From 0a1a94fc16e345376f572363757a0fe4c0bcc42b Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sat, 29 Aug 2026 23:07:10 +0200 Subject: [PATCH 12/35] test(cockpit): bind computed network keys by symbol Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N6vGgvRSKBesMRt15DqZhn --- tests/cockpit-host/purity.test.ts | 172 +++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 5 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index fdb139a..53f8b66 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1306,6 +1306,75 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou return found; }; +// F1/P2 — binder-identity gate for the NET free-global member check (D3-CX-POLICY-NET-BIND). +// +// `collectStringConsts` keys a resolved constant by identifier TEXT (under whole-file +// uniqueness), and `staticStringOf` then substitutes that value for ANY same-text identifier. +// For the NET free-global member path that is unsound: an element-access key such as +// `globalThis[Infinity]`, whose `Infinity` reference does NOT lexically resolve to an in-scope +// `const Infinity = 'fetch'` (a different-scope binding, a free global, a parameter/import +// shadow), would still be handed the collected `'fetch'` — a PHANTOM member and an +// outbound-network false positive, even though no fetch/WebSocket capability is acquired. +// Identifier text equality is not binding identity; the compiler binder is the authority. +// +// This gate requires the key Identifier to RESOLVE, via `checker.getSymbolAtLocation`, to the +// one unique `const = ` declaration that produced the collected value. +// A key that resolves to a DIFFERENT same-text binding, or to no in-file binding at all, is +// treated as unresolved for NET (null) — never as the collected const — and is left to the +// independent fail-closed runtime-code guard. String literals and `+`-folds of literals carry +// no identifier and are unaffected; a genuine same-binding `const key = 'fetch'; globalThis[key]` +// still resolves and is still rejected. Bounded to NET: `collectStringConsts` / `staticStringOf` +// / `memberNameOf` are unchanged, so the RC/HA text-only structural policy is not touched. +const bindsToCollectedConst = (id: ts.Identifier, checker: ts.TypeChecker): boolean => { + const symbol = checker.getSymbolAtLocation(id); + if (symbol === undefined || symbol.declarations === undefined || symbol.declarations.length !== 1) { + return false; + } + const decl = symbol.declarations[0]; + if (decl === undefined || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || decl.name.text !== id.text) { + return false; + } + const list = decl.parent; + return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0; +}; + +// `staticStringOf` restricted, at every Identifier leaf, to the const the binder proves the +// reference denotes (see `bindsToCollectedConst`). Used ONLY by the NET free-global member +// path. Structurally identical to `staticStringOf` otherwise — literals and `+`-folds are +// resolved exactly as before — so literal concatenation and genuine same-binding const keys +// keep resolving; only a same-text/different-symbol identifier is demoted to unresolved. +const netStaticStringOf = ( + node: ts.Expression, + constMap: ReadonlyMap, + checker: ts.TypeChecker, +): string | null => { + const n = unwrapExpr(node); + if (ts.isStringLiteralLike(n)) return n.text; + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = netStaticStringOf(n.left, constMap, checker); + if (left === null) return null; + const right = netStaticStringOf(n.right, constMap, checker); + return right === null ? null : left + right; + } + if (ts.isIdentifier(n)) { + const value = constMap.get(n.text); + if (value === undefined) return null; + return bindsToCollectedConst(n, checker) ? value : null; + } + return null; +}; + +// `memberNameOf` for the NET path: a property name is read directly; an element-access key is +// resolved with binder-identity gating (`netStaticStringOf`), never by identifier text alone. +const netMemberNameOf = ( + node: ts.PropertyAccessExpression | ts.ElementAccessExpression, + constMap: ReadonlyMap, + checker: ts.TypeChecker, +): string | null => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + return netStaticStringOf(node.argumentExpression, constMap, checker); +}; + /** * NET — reject outbound network egress, decided by TypeScript BINDER identity * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, @@ -1338,11 +1407,15 @@ const usesOutboundNetwork = (source: string): boolean => { const member = binderMemberName(node); if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; } - // F1 — a free-global network member (`fetch`/`WebSocket`) resolved through the existing - // static-string machinery: a direct literal, a `+`-fold (`'fe' + 'tch'`), or a unique - // immutable `const key = 'fetch'`. A genuinely indeterminate key resolves to `null` - // and is not flagged here (the runtime-code guard rejects it fail-closed). - const globalMember = memberNameOf(node, constMap); + // F1 — a free-global network member (`fetch`/`WebSocket`) resolved through the static-string + // machinery under a BINDER-IDENTITY gate (P2, `netMemberNameOf`): a direct literal, a + // `+`-fold (`'fe' + 'tch'`), or a unique immutable `const key = 'fetch'` whose reference + // the binder proves denotes that same const. A key whose identifier resolves to a + // different same-text binding (or to no in-file binding) is NOT substituted — it stays + // `null` here, so `globalThis[Infinity]` with an out-of-scope `const Infinity = 'fetch'` + // is not a phantom member. A genuinely indeterminate key likewise resolves to `null` and + // is not flagged here (the runtime-code guard rejects it fail-closed). + const globalMember = netMemberNameOf(node, constMap, checker); if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { found = true; } @@ -5034,3 +5107,92 @@ describe('D3 host rejects statically-computed free-global network members (D3-CX }); } }); + +// --------------------------------------------------------------------------- +// P2: the free-global network-member check resolves a computed key through a collected string +// constant by identifier TEXT (D3-CX-POLICY-NET-BIND). `collectStringConsts` keys a value by +// text under whole-file uniqueness, so a single out-of-scope `const Infinity = 'fetch'` made +// `globalThis[Infinity]` — whose `Infinity` reference does NOT resolve to that const — fold to +// a PHANTOM `'fetch'` member and reject as egress, a false positive that acquires no runtime +// fetch/WebSocket capability. The member is now resolved with `netMemberNameOf`, which +// substitutes a collected constant for an Identifier key ONLY when the compiler binder proves +// the reference denotes that same unique `const` declaration; identifier text equality is not +// binding identity. Literals and `+`-folds are unchanged, a genuine same-binding const key is +// still rejected, and a key that cannot be bound stays out of scope for NET and remains rejected +// fail-closed by the independent runtime-code guard. RC/HA text-only policy is untouched. +// --------------------------------------------------------------------------- +describe('D3 host resolves computed network-member keys by binder identity (D3-CX-POLICY-NET-BIND)', () => { + // The verified reproducer is a genuine FALSE POSITIVE: NET no longer flags it AND the + // runtime-code guard does not either — the source is truly accepted, not merely shifted. + it('accepts the verified reproducer (out-of-scope const Infinity) by both guards', () => { + const reproducer = `function f() {\n const Infinity = 'fetch';\n void Infinity;\n}\nvoid (globalThis as any)[Infinity];`; + expect(usesOutboundNetwork(reproducer)).toBe(false); + expect(usesRuntimeCodeGeneration(reproducer)).toBe(false); + }); + + // ALLOW — the collected `const = ''` exists (unique in the file) but the + // element-access key reference does NOT lexically resolve to it, so no substitution is proven. + const allowDifferentBinding: readonly { readonly form: string; readonly source: string }[] = [ + { + form: 'a function-scoped const Infinity with an outer key reference', + source: `function f() {\n const Infinity = 'fetch';\n void Infinity;\n}\nvoid (globalThis as any)[Infinity];`, + }, + { + form: 'a block-scoped const Infinity with an outer key reference', + source: `{\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`, + }, + { + form: 'a sibling-scope const key that the second scope does not see', + source: `function a() {\n const key = 'fetch';\n void key;\n}\nfunction b() {\n void globalThis[key];\n}\nvoid a;\nvoid b;`, + }, + { + form: 'an inner-declared const with an outer WebSocket key reference (constructor)', + source: `function outer() {\n function inner() {\n const wsName = 'WebSocket';\n void wsName;\n }\n void inner;\n return new globalThis[wsName]('wss://example.com/');\n}\nvoid outer;`, + }, + { + form: 'a parameter shadow of the collected const name', + source: `const routeName = 'fetch';\nfunction f(routeName: string) {\n return globalThis[routeName];\n}\nvoid f;`, + }, + { + form: 'an imported-name / inner-const same-text key resolving to the import', + source: `import { helper } from './x.js';\nfunction f() {\n const helper = 'fetch';\n void helper;\n}\nvoid helper;\nvoid globalThis[helper];`, + }, + ]; + for (const { form, source } of allowDifferentBinding) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // REJECT — genuine egress: literals, `+`-folds, and const keys whose reference the binder DOES + // prove denotes the collected const (same-symbol), for both fetch and WebSocket. + const rejectGenuine: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a same-binding const key globalThis[key]', source: `const key = 'fetch';\nglobalThis[key]('https://example.com/');` }, + { form: 'a same-binding const key new globalThis[ws]', source: `const ws = 'WebSocket';\nvoid new globalThis[ws]('wss://example.com/');` }, + { form: "a same-binding const-prefix globalThis[prefix + 'tch']", source: `const prefix = 'fe';\nglobalThis[prefix + 'tch']('https://example.com/');` }, + { form: "a literal concatenation globalThis['fe' + 'tch']", source: `globalThis['fe' + 'tch']('https://example.com/');` }, + { form: 'a dotted globalThis.fetch', source: `globalThis.fetch('https://example.com/');` }, + { form: 'a dotted new globalThis.WebSocket', source: `void new globalThis.WebSocket('wss://example.com/');` }, + { form: 'a same-scope const key inside a function (same symbol)', source: `function f() {\n const key = 'fetch';\n return globalThis[key]('https://example.com/');\n}\nvoid f;` }, + ]; + for (const { form, source } of rejectGenuine) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // FAIL-CLOSED — a key the NET branch cannot bind (ambient, mutated, or undeclared) is not + // flagged by NET but REMAINS rejected fail-closed by the runtime-code guard. Binder-identity + // gating narrows NET substitution WITHOUT opening an outbound-network bypass. + const failClosed: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ambient declare const key globalThis[k]', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, + { form: 'a mutated let key globalThis[k]', source: `let k = 'fetch';\nk = 'other';\nglobalThis[k]('https://example.com/');` }, + { form: 'an undeclared free-global key globalThis[neverDeclared]', source: `void globalThis[neverDeclared];` }, + ]; + for (const { form, source } of failClosed) { + it(`keeps ${form} out of NET but rejected by the runtime-code guard`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } +}); From e7b4fa6b73b2a471b373baae3fa270d25a2bfe1d Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 00:28:34 +0200 Subject: [PATCH 13/35] test(cockpit): memoize binder-safe network keys Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N6vGgvRSKBesMRt15DqZhn --- tests/cockpit-host/purity.test.ts | 337 +++++++++++++++++++++++++----- 1 file changed, 285 insertions(+), 52 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 53f8b66..4e32723 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1306,73 +1306,124 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou return found; }; -// F1/P2 — binder-identity gate for the NET free-global member check (D3-CX-POLICY-NET-BIND). +// F1/P2 — recursive binder-identity resolution for the NET free-global member check +// (D3-CX-POLICY-NET-BIND). `collectStringConsts` keys a resolved constant by identifier TEXT and, +// while resolving a `const`'s initializer, folds EVERY identifier leaf by text too. For the NET +// free-global member path that is unsound at two levels: (1) the element-access key identifier +// itself may resolve to a DIFFERENT same-text binding, and (2) even a genuine same-symbol key +// (`const key = Infinity`) can carry a value that was folded from an out-of-scope +// `const Infinity = 'fetch'` INSIDE its initializer — binding identity is lost during initializer +// resolution. Identifier text equality is never binding identity; the compiler binder is authority. // -// `collectStringConsts` keys a resolved constant by identifier TEXT (under whole-file -// uniqueness), and `staticStringOf` then substitutes that value for ANY same-text identifier. -// For the NET free-global member path that is unsound: an element-access key such as -// `globalThis[Infinity]`, whose `Infinity` reference does NOT lexically resolve to an in-scope -// `const Infinity = 'fetch'` (a different-scope binding, a free global, a parameter/import -// shadow), would still be handed the collected `'fetch'` — a PHANTOM member and an -// outbound-network false positive, even though no fetch/WebSocket capability is acquired. -// Identifier text equality is not binding identity; the compiler binder is the authority. -// -// This gate requires the key Identifier to RESOLVE, via `checker.getSymbolAtLocation`, to the -// one unique `const = ` declaration that produced the collected value. -// A key that resolves to a DIFFERENT same-text binding, or to no in-file binding at all, is -// treated as unresolved for NET (null) — never as the collected const — and is left to the -// independent fail-closed runtime-code guard. String literals and `+`-folds of literals carry -// no identifier and are unaffected; a genuine same-binding `const key = 'fetch'; globalThis[key]` -// still resolves and is still rejected. Bounded to NET: `collectStringConsts` / `staticStringOf` -// / `memberNameOf` are unchanged, so the RC/HA text-only structural policy is not touched. -const bindsToCollectedConst = (id: ts.Identifier, checker: ts.TypeChecker): boolean => { +// The NET path therefore resolves strings itself, straight off the binder, WITHOUT consulting the +// text-keyed const map: every Identifier hop — the key and every identifier reached while resolving +// a collected initializer — must resolve (via `checker.getSymbolAtLocation`) to a single +// `const = ` declaration of matching text, and that declaration's initializer is then +// resolved under the SAME discipline. A hop that resolves to a different same-text binding, to no +// in-file binding (a free global under `noLib`), or to a non-const/duplicate binding, demotes the +// whole chain to unresolved (`null`), left to the fail-closed runtime-code guard. String literals +// and `+`-folds of literals carry no identifier and resolve as before, so a genuine same-symbol +// const chain (`const a = 'fetch'; const key = a; globalThis[key]`) still folds and is still +// rejected. `seen` bounds recursion: a const-initializer cycle terminates at `null`. Bounded to +// NET: `collectStringConsts` / `staticStringOf` / `memberNameOf` are unchanged, so the RC/HA +// text-only structural policy is not touched. + +// The single `const = ` declaration a binder-resolved identifier denotes, else null: +// the symbol has exactly one declaration, that declaration is a `const` VariableDeclaration whose +// name text matches the reference. Text equality alone never qualifies — a different-scope or +// free-global reference resolves to a different symbol (or none) and is rejected here. +const netUniqueConstDecl = (id: ts.Identifier, checker: ts.TypeChecker): ts.VariableDeclaration | null => { const symbol = checker.getSymbolAtLocation(id); if (symbol === undefined || symbol.declarations === undefined || symbol.declarations.length !== 1) { - return false; + return null; } const decl = symbol.declarations[0]; if (decl === undefined || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || decl.name.text !== id.text) { - return false; + return null; } const list = decl.parent; - return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0; + return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 ? decl : null; }; -// `staticStringOf` restricted, at every Identifier leaf, to the const the binder proves the -// reference denotes (see `bindsToCollectedConst`). Used ONLY by the NET free-global member -// path. Structurally identical to `staticStringOf` otherwise — literals and `+`-folds are -// resolved exactly as before — so literal concatenation and genuine same-binding const keys -// keep resolving; only a same-text/different-symbol identifier is demoted to unresolved. -const netStaticStringOf = ( +// The statically-provable string an expression denotes for the NET path, resolved entirely by the +// binder: a string literal or substitution-free template, a `+`-fold of such, or an Identifier the +// binder proves is a unique `const` whose initializer resolves the same way — recursively, with +// binder identity required at EVERY hop. +// +// Two DISTINCT bookkeeping structures, both keyed by DECLARATION identity (never identifier text), +// so a shared initializer subtree is resolved once instead of exponentially (P2 memoization): +// - `seen`: the declarations on the CURRENT resolution path, so an initializer cycle yields +// `null` in finite time (a declaration re-entered before it completes is a cycle). +// - `memo`: the COMPLETED result of each declaration (a `string`, or `null` for unresolved), +// so a second reference to the same declaration — a diamond/doubling chain such as +// `aN = aN-1 + aN-1` — reads the cached result rather than recomputing its whole subtree. +// `memo.has` distinguishes a cached `null` from "not yet computed", so a genuine unresolved +// result never silently becomes a static value. The cache is per top-level key resolution and +// keyed by the binder-proven declaration node, so a result for one declaration is NEVER reused +// for a different same-text declaration in another scope. +// +// A per-resolution `budget` records the identifier hops spent (`netLastResolveVisits`, read by the +// memoization regression test as deterministic structural evidence) and CAPS them: memoization +// keeps the hop count linear in the number of const declarations, so a doubling chain costs O(N), +// never O(2^N). Because resolution is synchronous, an un-memoized regression would block the event +// loop rather than trip any test timeout — the cap converts that into a fast, deterministic failure. +// The cap sits far above anything real host/Cockpit source or any genuine const chain produces, so +// it never fires in normal classification. +const NET_RESOLVE_VISIT_CAP = 200_000; +let netLastResolveVisits = 0; + +const netResolveString = ( node: ts.Expression, - constMap: ReadonlyMap, checker: ts.TypeChecker, + seen: Set, + memo: Map, + budget: { spent: number }, ): string | null => { const n = unwrapExpr(node); if (ts.isStringLiteralLike(n)) return n.text; if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = netStaticStringOf(n.left, constMap, checker); + const left = netResolveString(n.left, checker, seen, memo, budget); if (left === null) return null; - const right = netStaticStringOf(n.right, constMap, checker); + const right = netResolveString(n.right, checker, seen, memo, budget); return right === null ? null : left + right; } if (ts.isIdentifier(n)) { - const value = constMap.get(n.text); - if (value === undefined) return null; - return bindsToCollectedConst(n, checker) ? value : null; + budget.spent += 1; + if (budget.spent > NET_RESOLVE_VISIT_CAP) { + throw new Error('NET constant resolution exceeded its visit budget (memoization regression)'); + } + const decl = netUniqueConstDecl(n, checker); + if (decl === null || decl.initializer === undefined) return null; + if (memo.has(decl)) return memo.get(decl) ?? null; // completed result (string or cached null) + if (seen.has(decl)) return null; // re-entry before completion: cycle (do not cache) + seen.add(decl); + const value = netResolveString(decl.initializer, checker, seen, memo, budget); + seen.delete(decl); + memo.set(decl, value); + return value; } return null; }; // `memberNameOf` for the NET path: a property name is read directly; an element-access key is -// resolved with binder-identity gating (`netStaticStringOf`), never by identifier text alone. +// resolved by `netResolveString` (binder identity at every hop), never by identifier text alone. +// A fresh `seen`/`memo`/`budget` triple scopes cycle detection, memoization, and the hop budget to +// this one key resolution; the hops spent are published to `netLastResolveVisits`. const netMemberNameOf = ( node: ts.PropertyAccessExpression | ts.ElementAccessExpression, - constMap: ReadonlyMap, checker: ts.TypeChecker, ): string | null => { if (ts.isPropertyAccessExpression(node)) return node.name.text; - return netStaticStringOf(node.argumentExpression, constMap, checker); + const budget = { spent: 0 }; + const resolved = netResolveString( + node.argumentExpression, + checker, + new Set(), + new Map(), + budget, + ); + netLastResolveVisits = budget.spent; + return resolved; }; /** @@ -1390,12 +1441,10 @@ const netMemberNameOf = ( */ const usesOutboundNetwork = (source: string): boolean => { const { checker, sourceFile } = buildBinderProgram(source); - // Statically-resolvable string constants for the free-global network-member check (F1): - // reuses the existing `collectStringConsts` / `memberNameOf` machinery so a computed member - // key that folds to a literal (`'fe' + 'tch'`, a `const key = 'fetch'`) is classified like a - // direct literal. A key that does not statically resolve stays `null` here and remains - // rejected fail-closed by the independent runtime-code guard — this never weakens that. - const constMap = collectStringConsts(sourceFile); + // Free-global network members (F1/P2) are resolved by `netMemberNameOf`, which reads string + // constants straight off the binder — identity required at the key AND at every initializer hop + // (see `netResolveString`) — rather than through the text-keyed const map. A key that does not + // statically resolve stays `null` and remains rejected fail-closed by the runtime-code guard. let found = false; const visit = (node: ts.Node): void => { // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. @@ -1407,15 +1456,15 @@ const usesOutboundNetwork = (source: string): boolean => { const member = binderMemberName(node); if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; } - // F1 — a free-global network member (`fetch`/`WebSocket`) resolved through the static-string - // machinery under a BINDER-IDENTITY gate (P2, `netMemberNameOf`): a direct literal, a - // `+`-fold (`'fe' + 'tch'`), or a unique immutable `const key = 'fetch'` whose reference - // the binder proves denotes that same const. A key whose identifier resolves to a - // different same-text binding (or to no in-file binding) is NOT substituted — it stays - // `null` here, so `globalThis[Infinity]` with an out-of-scope `const Infinity = 'fetch'` - // is not a phantom member. A genuinely indeterminate key likewise resolves to `null` and - // is not flagged here (the runtime-code guard rejects it fail-closed). - const globalMember = netMemberNameOf(node, constMap, checker); + // F1/P2 — a free-global network member (`fetch`/`WebSocket`) resolved by `netMemberNameOf` + // under RECURSIVE binder identity: a direct literal, a `+`-fold (`'fe' + 'tch'`), or a + // unique immutable `const` chain (`const a = 'fetch'; const key = a`) whose key AND every + // initializer identifier the binder proves denote the collected const. A key — or any + // initializer identifier in its chain — that resolves to a different same-text binding + // (or to no in-file binding, e.g. `const key = Infinity` folding an out-of-scope + // `const Infinity = 'fetch'`) is NOT substituted; it stays `null` here. A genuinely + // indeterminate key likewise resolves to `null` (the runtime-code guard rejects it). + const globalMember = netMemberNameOf(node, checker); if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { found = true; } @@ -5196,3 +5245,187 @@ describe('D3 host resolves computed network-member keys by binder identity (D3-C }); } }); + +// --------------------------------------------------------------------------- +// P2 (follow-up): binder identity must hold through the ENTIRE initializer-resolution chain, not +// only at the final element-access key (D3-CX-POLICY-NET-BIND-INIT). Gating just the key left a +// second hole: `const key = Infinity` is itself a genuine same-symbol const reference, but its +// initializer `Infinity` was resolved by text-only const collection and folded to a phantom +// `'fetch'` from an out-of-scope `const Infinity = 'fetch'`. The NET path now resolves strings +// straight off the binder (`netResolveString`): every identifier hop — the key and every +// identifier reached while resolving a collected initializer — must resolve to the unique `const` +// declaration whose value is being substituted, recursively, bounded against initializer cycles. +// A genuine multi-hop same-symbol chain still folds and is still rejected; a chain poisoned at any +// hop demotes to unresolved and is left to the fail-closed runtime-code guard. +// --------------------------------------------------------------------------- +describe('D3 host binds identifiers inside collected constant initializers (D3-CX-POLICY-NET-BIND-INIT)', () => { + // The verified reproducer is a genuine FALSE POSITIVE: NET no longer flags it AND the + // runtime-code guard does not either — the source is truly accepted, not merely shifted. + it('accepts the verified reproducer (poisoned initializer const key = Infinity) by both guards', () => { + const reproducer = `function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`; + expect(usesOutboundNetwork(reproducer)).toBe(false); + expect(usesRuntimeCodeGeneration(reproducer)).toBe(false); + }); + + // ALLOW — the key is a genuine unique const, but an identifier INSIDE its initializer chain does + // not lexically resolve to the collected declaration, so the fold is not binder-proven. + const allowPoisonedInit: readonly { readonly form: string; readonly source: string }[] = [ + { + form: 'a function-scoped const Infinity folded into a module const initializer', + source: `function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`, + }, + { + form: 'a block-scoped const Infinity folded into a module const initializer', + source: `{\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`, + }, + { + form: 'a sibling-scope const marker folded into a module const initializer', + source: `function f() {\n const marker = 'fetch';\n}\nconst key = marker;\nvoid globalThis[key];`, + }, + { + form: 'a concatenation initializer where one segment resolves to a different binding', + source: `function f() {\n const seg = 'tch';\n}\nconst a = 'fe';\nconst key = a + seg;\nvoid globalThis[key];`, + }, + { + form: 'a parameter-shadow initializer', + source: `const label = 'fetch';\nfunction f(label: string) {\n const key = label;\n return globalThis[key];\n}\nvoid f;`, + }, + { + form: 'an import-shadow initializer', + source: `import { thing } from './x.js';\nconst key = thing;\nvoid globalThis[key];`, + }, + { + form: 'a free-global identifier initializer under noLib', + source: `const key = Infinity;\nvoid globalThis[key];`, + }, + { + form: 'a same-text sibling-scope const referenced from another function initializer', + source: `function a() {\n const Infinity = 'fetch';\n void Infinity;\n}\nfunction b() {\n const key = Infinity;\n void globalThis[key];\n}\nvoid a;\nvoid b;`, + }, + ]; + for (const { form, source } of allowPoisonedInit) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // REJECT — genuine multi-hop same-symbol chains: every identifier resolves to the collected + // declaration, for both fetch and WebSocket, call and constructor, incl. an optional key. + const rejectGenuineChain: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a one-hop const key = a', source: `const a = 'fetch';\nconst key = a;\nglobalThis[key]('https://example.com/');` }, + { form: "a concat const key = a + 'tch'", source: `const a = 'fe';\nconst key = a + 'tch';\nglobalThis[key]('https://example.com/');` }, + { form: 'a two-hop const key = b = a chain', source: `const a = 'fe';\nconst b = a + 'tch';\nconst key = b;\nglobalThis[key]('https://example.com/');` }, + { form: 'a WebSocket const ws = a', source: `const a = 'WebSocket';\nconst ws = a;\nvoid new globalThis[ws]('wss://example.com/');` }, + { form: "a WebSocket concat const ws = a + 'Socket'", source: `const a = 'Web';\nconst ws = a + 'Socket';\nvoid new globalThis[ws]('wss://example.com/');` }, + { form: 'an optional-computed same-symbol key', source: `const key = 'fetch';\nglobalThis?.[key]('https://example.com/');` }, + { form: 'a direct literal element key', source: `globalThis['fetch']('https://example.com/');` }, + ]; + for (const { form, source } of rejectGenuineChain) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // FAIL-CLOSED — a chain the NET path cannot binder-prove (ambient initializer, or a cycle) is + // not flagged by NET but REMAINS rejected fail-closed by the runtime-code guard, and cycle + // resolution TERMINATES. No outbound-network bypass is opened. + const failClosedChain: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ambient-declare initializer const key = k', source: `declare const k: string;\nconst key = k;\nglobalThis[key]('https://example.com/');` }, + { form: 'a two-const initializer cycle a = b, b = a', source: `const a = b;\nconst b = a;\nvoid globalThis[a];` }, + { form: 'a self-referential initializer const a = a', source: `const a = a;\nvoid globalThis[a];` }, + ]; + for (const { form, source } of failClosedChain) { + it(`keeps ${form} out of NET but rejected by the runtime-code guard (terminates)`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } +}); + +// --------------------------------------------------------------------------- +// P2 (second-order): NET recursive resolution must MEMOIZE by declaration identity, not merely +// detect cycles path-locally (D3-CX-POLICY-NET-BIND-MEMO). Without a completed-result cache a +// shared initializer subtree is recomputed on every reference, so a doubling chain +// `aN = aN-1 + aN-1` costs 2^N resolutions (~22.5 s at N=22 on ~500 bytes). `netResolveString` +// now caches each declaration's completed result (string or null) keyed by the binder-proven +// declaration node — distinct from the cycle set — so every declaration's initializer is resolved +// at most once. Binder identity stays authoritative at every hop, the cache never crosses scopes +// (it is keyed by declaration, never text), a cached null never becomes a static value, and cycles +// still terminate fail-closed. +// --------------------------------------------------------------------------- +describe('D3 host memoizes NET constant resolution by declaration identity (D3-CX-POLICY-NET-BIND-MEMO)', () => { + const doubling = (n: number, base: string): string => { + const lines = [`const a0 = '${base}';`]; + for (let i = 1; i <= n; i++) lines.push(`const a${String(i)} = a${String(i - 1)} + a${String(i - 1)};`); + lines.push(`void globalThis[a${String(n)}];`); + return lines.join('\n'); + }; + const linear = (n: number, base: string): string => { + const lines = [`const a0 = '${base}';`]; + for (let i = 1; i <= n; i++) lines.push(`const a${String(i)} = a${String(i - 1)};`); + lines.push(`globalThis[a${String(n)}]('https://example.com/');`); + return lines.join('\n'); + }; + + // Shared-subtree (doubling) chain. With an empty base the resolved value is O(1) while the + // recomputation tree is 2^N without memo. `netLastResolveVisits` is the number of identifier hops + // the resolver actually spent: declaration-keyed memoization keeps it LINEAR in the declaration + // count (~2N), so a small bound here is deterministic structural evidence of memoization. An + // un-memoized resolver would spend 2^60 hops — impossible — and trip the visit cap at once + // (a fast throw, not a hang). The empty key is not a network global, so NET is not flagged. + it('resolves a shared-subtree doubling chain N=60 in a linear number of hops (memoized)', () => { + netLastResolveVisits = 0; + expect(usesOutboundNetwork(doubling(60, ''))).toBe(false); + expect(netLastResolveVisits).toBeGreaterThan(0); + expect(netLastResolveVisits).toBeLessThan(1000); + }); + + // The exact reported adversarial family (a0 = 'fe'), well beyond the prior N=22 failure point: + // the hop count is likewise linear, and the once-built value is not a network global. + it('resolves the reported doubling family N=24 (a0 = fe) in a linear number of hops', () => { + netLastResolveVisits = 0; + expect(usesOutboundNetwork(doubling(24, 'fe'))).toBe(false); + expect(netLastResolveVisits).toBeGreaterThan(0); + expect(netLastResolveVisits).toBeLessThan(1000); + }); + + // A long linear chain still resolves and rejects the genuine capability, fast. + it('rejects a long linear const chain resolving to fetch (N=40)', () => { + expect(usesOutboundNetwork(linear(40, 'fetch'))).toBe(true); + }, 4000); + + // Cycles: memo + cycle interaction terminates and stays fail-closed (NET null, RC rejects). + const cycles: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a self cycle const a = a', source: `const a = a;\nglobalThis[a]('https://example.com/');` }, + { form: 'a 2-node cycle a = b, b = a', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');` }, + { form: 'a 3-node cycle a = b, b = c, c = a', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');` }, + ]; + for (const { form, source } of cycles) { + it(`keeps ${form} out of NET but rejected fail-closed (terminates)`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }, 4000); + } + + // Declaration-identity cache must not leak between same-text declarations in different scopes. + it('rejects globalThis[s] where s resolves to a function-local const fetch', () => { + expect( + usesOutboundNetwork(`function f() {\n const s = 'fetch';\n return globalThis[s]('https://example.com/');\n}\nvoid f;`), + ).toBe(true); + }); + it('allows globalThis[s] where a same-text s resolves to a benign function-local const', () => { + expect(usesOutboundNetwork(`function g() {\n const s = 'safe';\n return globalThis[s];\n}\nvoid g;`)).toBe(false); + }); + it('does not reuse a module const fetch value for a shadowing function-local const', () => { + expect( + usesOutboundNetwork(`const a = 'fetch';\nfunction f() {\n const a = 'safe';\n return globalThis[a];\n}\nvoid f;\nvoid a;`), + ).toBe(false); + }); + + // Two distinct declarations sharing initializer text each resolve independently (no conflation). + it('rejects the genuine one of two distinct decls sharing initializer text', () => { + expect( + usesOutboundNetwork(`const m1 = 'fetch';\nconst m2 = 'fetch';\nvoid m1;\nglobalThis[m2]('https://example.com/');`), + ).toBe(true); + }); +}); From f2da06abe3ae50aeb737e632c1d56cdc5979356b Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 09:44:30 +0200 Subject: [PATCH 14/35] test(cockpit): share binder-safe network memo Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N6vGgvRSKBesMRt15DqZhn --- tests/cockpit-host/purity.test.ts | 190 ++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 33 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 4e32723..15d0760 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1362,15 +1362,16 @@ const netUniqueConstDecl = (id: ts.Identifier, checker: ts.TypeChecker): ts.Vari // keyed by the binder-proven declaration node, so a result for one declaration is NEVER reused // for a different same-text declaration in another scope. // -// A per-resolution `budget` records the identifier hops spent (`netLastResolveVisits`, read by the -// memoization regression test as deterministic structural evidence) and CAPS them: memoization -// keeps the hop count linear in the number of const declarations, so a doubling chain costs O(N), -// never O(2^N). Because resolution is synchronous, an un-memoized regression would block the event -// loop rather than trip any test timeout — the cap converts that into a fast, deterministic failure. -// The cap sits far above anything real host/Cockpit source or any genuine const chain produces, so -// it never fires in normal classification. +// A per-member `budget` records the identifier hops ONE member resolution spends and CAPS them: +// memoization keeps the hop count linear in the number of const declarations, so a doubling chain +// costs O(N), never O(2^N). Because resolution is synchronous, an un-memoized regression would +// block the event loop rather than trip a test timeout — the cap converts that into a fast, +// deterministic failure. `netResolveVisits` accumulates hops across ALL member resolutions of ONE +// `usesOutboundNetwork` traversal (reset at its start), so a test can prove the CROSS-member cost +// is O(N + M) — a chain reused by M accesses is resolved once — not O(M × N). Both bounds sit far +// above anything real host/Cockpit source or any genuine const chain produces. const NET_RESOLVE_VISIT_CAP = 200_000; -let netLastResolveVisits = 0; +let netResolveVisits = 0; const netResolveString = ( node: ts.Expression, @@ -1388,7 +1389,8 @@ const netResolveString = ( return right === null ? null : left + right; } if (ts.isIdentifier(n)) { - budget.spent += 1; + netResolveVisits += 1; // cumulative across the whole usesOutboundNetwork traversal (test evidence) + budget.spent += 1; // per-member cap: prevents a single member's exponential from hanging if (budget.spent > NET_RESOLVE_VISIT_CAP) { throw new Error('NET constant resolution exceeded its visit budget (memoization regression)'); } @@ -1407,23 +1409,18 @@ const netResolveString = ( // `memberNameOf` for the NET path: a property name is read directly; an element-access key is // resolved by `netResolveString` (binder identity at every hop), never by identifier text alone. -// A fresh `seen`/`memo`/`budget` triple scopes cycle detection, memoization, and the hop budget to -// this one key resolution; the hops spent are published to `netLastResolveVisits`. +// The completed-result `memo` is SHARED across every member resolution of one `usesOutboundNetwork` +// traversal, so a const chain reused by many accesses is resolved once (O(N + M), not O(M × N)). A +// fresh `seen`/`budget` per call keeps active-path cycle detection and the per-member cap local: a +// completed result is context-independent, so sharing it is sound, whereas sharing in-progress path +// state would not be. const netMemberNameOf = ( node: ts.PropertyAccessExpression | ts.ElementAccessExpression, checker: ts.TypeChecker, + memo: Map, ): string | null => { if (ts.isPropertyAccessExpression(node)) return node.name.text; - const budget = { spent: 0 }; - const resolved = netResolveString( - node.argumentExpression, - checker, - new Set(), - new Map(), - budget, - ); - netLastResolveVisits = budget.spent; - return resolved; + return netResolveString(node.argumentExpression, checker, new Set(), memo, { spent: 0 }); }; /** @@ -1445,6 +1442,13 @@ const usesOutboundNetwork = (source: string): boolean => { // constants straight off the binder — identity required at the key AND at every initializer hop // (see `netResolveString`) — rather than through the text-keyed const map. A key that does not // statically resolve stays `null` and remains rejected fail-closed by the runtime-code guard. + // + // ONE completed-result memo is shared by every member resolution in THIS traversal, so a const + // chain reused across many accesses is resolved once (O(N + M), not O(M × N)). It is keyed by the + // binder's declaration nodes for this Program, so it cannot leak into another `usesOutboundNetwork` + // call. `netResolveVisits` is reset here to make the cumulative hop count observable to tests. + const netMemo = new Map(); + netResolveVisits = 0; let found = false; const visit = (node: ts.Node): void => { // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. @@ -1464,7 +1468,7 @@ const usesOutboundNetwork = (source: string): boolean => { // (or to no in-file binding, e.g. `const key = Infinity` folding an out-of-scope // `const Infinity = 'fetch'`) is NOT substituted; it stays `null` here. A genuinely // indeterminate key likewise resolves to `null` (the runtime-code guard rejects it). - const globalMember = netMemberNameOf(node, checker); + const globalMember = netMemberNameOf(node, checker, netMemo); if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { found = true; } @@ -5368,25 +5372,23 @@ describe('D3 host memoizes NET constant resolution by declaration identity (D3-C }; // Shared-subtree (doubling) chain. With an empty base the resolved value is O(1) while the - // recomputation tree is 2^N without memo. `netLastResolveVisits` is the number of identifier hops - // the resolver actually spent: declaration-keyed memoization keeps it LINEAR in the declaration - // count (~2N), so a small bound here is deterministic structural evidence of memoization. An - // un-memoized resolver would spend 2^60 hops — impossible — and trip the visit cap at once - // (a fast throw, not a hang). The empty key is not a network global, so NET is not flagged. + // recomputation tree is 2^N without memo. `netResolveVisits` is the number of identifier hops the + // resolver actually spent across this single-access traversal: declaration-keyed memoization keeps + // it LINEAR in the declaration count (~2N), so a small bound here is deterministic structural + // evidence of memoization. An un-memoized resolver would spend 2^60 hops — impossible — and trip + // the visit cap at once (a fast throw, not a hang). The empty key is not a network global. it('resolves a shared-subtree doubling chain N=60 in a linear number of hops (memoized)', () => { - netLastResolveVisits = 0; expect(usesOutboundNetwork(doubling(60, ''))).toBe(false); - expect(netLastResolveVisits).toBeGreaterThan(0); - expect(netLastResolveVisits).toBeLessThan(1000); + expect(netResolveVisits).toBeGreaterThan(0); + expect(netResolveVisits).toBeLessThan(1000); }); // The exact reported adversarial family (a0 = 'fe'), well beyond the prior N=22 failure point: // the hop count is likewise linear, and the once-built value is not a network global. it('resolves the reported doubling family N=24 (a0 = fe) in a linear number of hops', () => { - netLastResolveVisits = 0; expect(usesOutboundNetwork(doubling(24, 'fe'))).toBe(false); - expect(netLastResolveVisits).toBeGreaterThan(0); - expect(netLastResolveVisits).toBeLessThan(1000); + expect(netResolveVisits).toBeGreaterThan(0); + expect(netResolveVisits).toBeLessThan(1000); }); // A long linear chain still resolves and rejects the genuine capability, fast. @@ -5429,3 +5431,125 @@ describe('D3 host memoizes NET constant resolution by declaration identity (D3-C ).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// P2 (cross-member): the completed-result memo must be SHARED across every member resolution in one +// `usesOutboundNetwork` traversal (D3-CX-POLICY-NET-BIND-XMEMO). Previously `netMemberNameOf` built +// a fresh memo per access, so an N-declaration chain reused by M member accesses was resolved M +// times — Θ(M × N) cumulative, unbounded by the per-member cap. One memo hoisted into the traversal +// makes the cumulative cost O(N + M): each declaration is resolved once and reused. The memo stays +// keyed by exact declaration identity and is created fresh per traversal, so it never leaks between +// sources; per-member `seen`/budget stay local, preserving cycle detection and the cap. +// --------------------------------------------------------------------------- +describe('D3 host shares the NET declaration memo across member resolutions (D3-CX-POLICY-NET-BIND-XMEMO)', () => { + const chain = (n: number, base: string): string[] => { + const lines = [`const a0 = '${base}';`]; + for (let i = 1; i <= n; i++) lines.push(`const a${String(i)} = a${String(i - 1)} + a${String(i - 1)};`); + return lines; + }; + + // 1. Same key repeated M times — cumulative work grows ADDITIVELY with M, not multiplicatively. + it('resolves a repeated key chain in additive (O(N+M)), not multiplicative (O(N*M)), work', () => { + const src = (n: number, m: number): string => { + const lines = chain(n, ''); + for (let j = 0; j < m; j++) lines.push(`void globalThis[a${String(n)}];`); + return lines.join('\n'); + }; + expect(usesOutboundNetwork(src(50, 1))).toBe(false); + const c1 = netResolveVisits; + expect(usesOutboundNetwork(src(50, 100))).toBe(false); + const c100 = netResolveVisits; + // Shared memo: the 50-chain is resolved once; each extra access is a single memoized hop, so + // 100 accesses cost the 1-access cost plus ~M. Un-shared memo would give c100 ≈ 100 × c1. + expect(c1).toBeGreaterThan(0); + expect(c100).toBeLessThan(c1 + 400); + }); + + // 2. One chain referenced by many DISTINCT key declarations — resolved once, shared by all. + it('shares one chain across many distinct key declarations', () => { + const lines = chain(50, ''); + const m = 100; + for (let j = 0; j < m; j++) lines.push(`const k${String(j)} = a50;`); + for (let j = 0; j < m; j++) lines.push(`void globalThis[k${String(j)}];`); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); + // a0..a50 resolved ONCE and shared; each of 100 keys adds O(1). Un-shared → ~100× more hops. + expect(netResolveVisits).toBeLessThan(1000); + }); + + // 3. Same chain read through many UNRELATED (non-global) receivers — resolution still shared. + it('shares chain resolution across unrelated non-global receivers', () => { + const lines = chain(50, ''); + lines.push('const obj: Record = {};'); + lines.push('const key = a50;'); + const m = 100; + for (let j = 0; j < m; j++) lines.push('void obj[key];'); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); // obj is not a global receiver + expect(netResolveVisits).toBeLessThan(1000); + }); + + // 4. Independent chains are cached separately (no cross-chain contamination); WebSocket rejected. + it('caches independent chains separately', () => { + const src = [ + `const a0 = 'fe';`, + `const a1 = a0 + a0;`, + `const b0 = 'WebSocket';`, + `const bk = b0;`, + `void globalThis[a1];`, // 'fefe' — not a network global → allowed + `new globalThis[bk]('wss://example.com/');`, // WebSocket → rejected + ].join('\n'); + expect(usesOutboundNetwork(src)).toBe(true); + }); + + // Cache lifetime: the memo must NOT leak between separate usesOutboundNetwork calls (fresh memo, + // declaration-keyed for each Program), in either order. + it('does not leak the memo between separate usesOutboundNetwork calls', () => { + expect(usesOutboundNetwork(`const key = 'safe';\nvoid globalThis[key];`)).toBe(false); + expect(usesOutboundNetwork(`const key = 'fetch';\nglobalThis[key]('https://example.com/');`)).toBe(true); + expect(usesOutboundNetwork(`const key = 'fetch';\nglobalThis[key]('https://example.com/');`)).toBe(true); + expect(usesOutboundNetwork(`const key = 'safe';\nvoid globalThis[key];`)).toBe(false); + }); + + // A failed (poisoned → null) chain and a genuine chain coexist in the shared memo without + // contaminating one another (both orders exercised by the two accesses). + it('keeps a poisoned (null) and a genuine chain independent within one source', () => { + const src = [ + `function f() { const p = 'fetch'; void p; }`, + `const bad = p;`, // out-of-scope p → null → that access allowed + `const good = 'fetch';`, + `void globalThis[bad];`, + `globalThis[good]('https://example.com/');`, // genuine → rejected overall + ].join('\n'); + expect(usesOutboundNetwork(src)).toBe(true); + }); + + // Cached NULL is reused for a repeated poisoned key (no per-access recomputation, still allowed). + it('reuses a cached null result for a repeated poisoned key', () => { + const lines = [`function f() { const marker = 'fetch'; void marker; }`, `const key = marker;`]; + for (let j = 0; j < 50; j++) lines.push('void globalThis[key];'); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); + expect(netResolveVisits).toBeLessThan(1000); + }); + + // Cycles still terminate and fail closed with the shared memo (in-progress state is never cached). + const cycles: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'self cycle', source: `const a = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[a];` }, + { form: '2-node cycle', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[b];` }, + { form: '3-node cycle', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[c];` }, + ]; + for (const { form, source } of cycles) { + it(`terminates a ${form} and fails closed with a shared memo`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // Prior binding fixes are preserved under the shared memo: poisoned keys allowed, genuine rejected. + it('preserves the poisoned-initializer ALLOW under the shared memo', () => { + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(false); + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(false); + }); + it('preserves the genuine-chain REJECT under the shared memo', () => { + expect(usesOutboundNetwork(`const a = 'fetch';\nconst b = a;\nconst key = b;\nglobalThis[key]('https://example.com/');`)).toBe(true); + expect(usesOutboundNetwork(`const a = 'Web';\nconst ws = a + 'Socket';\nvoid new globalThis[ws]('wss://example.com/');`)).toBe(true); + }); +}); From bc6a5eb98860842d2b8d45612637eca9ff5fe11f Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 12:46:08 +0200 Subject: [PATCH 15/35] test(cockpit): bound NET resolver resource usage --- tests/cockpit-host/purity.test.ts | 324 ++++++++++++++++++++++++++++-- 1 file changed, 308 insertions(+), 16 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 15d0760..f4181c9 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1371,37 +1371,112 @@ const netUniqueConstDecl = (id: ts.Identifier, checker: ts.TypeChecker): ts.Vari // is O(N + M) — a chain reused by M accesses is resolved once — not O(M × N). Both bounds sit far // above anything real host/Cockpit source or any genuine const chain produces. const NET_RESOLVE_VISIT_CAP = 200_000; + +// RESOLVER-TOTALITY BOUNDS (D3-CX-POLICY-NET-BIND-TOTALITY). Memoization bounds the NUMBER of +// declaration visits, but two independent quantities were unbounded, each turning a small valid +// source into a crash/OOM rather than a bounded policy verdict (verified Codex P2 pair): +// +// 1. OUTPUT LENGTH. `left + right` materialized the folded string with no size bound, so a +// doubling chain (`a0 = 'x'; aN = aN-1 + aN-1`) built a 2^N-char string on ~2N visits — +// the visit cap never fired. Because `+` only ADDS characters, a (sub)result longer than +// the longest capability name can never become a NETWORK_GLOBAL_NAMES member through +// further concatenation, so the fold stops tracking it and returns `null` BEFORE allocating +// the oversized string. This is a SEMANTIC result (a declaration's resolved length is +// intrinsic to its own initializer, independent of the caller), hence safely memoizable. +// +// 2. RECURSION DEPTH. A long chain recursed once per hop, so Node's native call stack threw +// an uncaught `RangeError` (~7.8k frames) far below the visit cap. The identifier-ALIAS +// spine (`const a = b; const b = c; …`) is now resolved ITERATIVELY — a chain of any length +// consumes O(1) native stack, so a genuine long chain to `fetch`/`WebSocket` still resolves +// and is still rejected (rather than crashing, or worse, being demoted to `null` and slipping +// past NET). Only a non-identifier initializer (a literal or `+`-fold) recurses, and a hard +// `NET_RESOLVE_DEPTH_CAP` — deterministic, far below the native stack limit and far above any +// real host/Cockpit source or genuine capability fold — bounds that remaining recursion. +// Exceeding it is a RESOURCE-BOUND ABORT: unlike the length bound it is context-DEPENDENT +// (it depends on the depth from which a declaration was reached), so it is signalled by +// `NetResolveAbort` and NEVER written to `memo` — a declaration aborted on a deep path still +// resolves normally when later reached from a shallow one. The visit-budget ceiling now +// aborts the same way instead of throwing an uncaught `Error`. +// +// `netMemberNameOf` catches `NetResolveAbort` and folds it to `null`; the independent runtime-code +// guard then rejects a computed free-global key fail-closed, so a resource bound narrows NET +// WITHOUT opening an outbound-network bypass. (A deeply nested LITERAL `+` expression overflows +// the shared `ts.forEachChild` AST walk that every detector in this file uses, before this +// resolver is reached — a pre-existing whole-file traversal limit, out of scope here.) +const NET_RESOLVE_DEPTH_CAP = 2_000; +// The longest network-capability name (`WebSocket` = 9). Derived from the policy set so it stays +// correct if the set changes; a fold whose result would exceed it cannot equal any member name. +const MAX_NETWORK_MEMBER_LENGTH = Math.max(...[...NETWORK_GLOBAL_NAMES].map((name) => name.length)); let netResolveVisits = 0; +// A resource-bound abort (recursion depth or visit budget) — DISTINCT from a semantic `null`. +// Thrown (not returned) so no partially-resolved declaration on the aborted path is memoized, and +// caught at the member boundary where it becomes a bounded `null` verdict rather than a crash. +class NetResolveAbort extends Error {} + const netResolveString = ( node: ts.Expression, checker: ts.TypeChecker, seen: Set, memo: Map, budget: { spent: number }, + depth: number, ): string | null => { + if (depth > NET_RESOLVE_DEPTH_CAP) throw new NetResolveAbort(); // resource bound: not memoized const n = unwrapExpr(node); if (ts.isStringLiteralLike(n)) return n.text; if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = netResolveString(n.left, checker, seen, memo, budget); + const left = netResolveString(n.left, checker, seen, memo, budget, depth + 1); if (left === null) return null; - const right = netResolveString(n.right, checker, seen, memo, budget); - return right === null ? null : left + right; + const right = netResolveString(n.right, checker, seen, memo, budget, depth + 1); + if (right === null) return null; + // Bound OUTPUT before allocating `left + right`: an oversized result can never equal a + // capability name, so stop tracking it — no exponential intermediate is ever materialized. + if (left.length + right.length > MAX_NETWORK_MEMBER_LENGTH) return null; + return left + right; } if (ts.isIdentifier(n)) { - netResolveVisits += 1; // cumulative across the whole usesOutboundNetwork traversal (test evidence) - budget.spent += 1; // per-member cap: prevents a single member's exponential from hanging - if (budget.spent > NET_RESOLVE_VISIT_CAP) { - throw new Error('NET constant resolution exceeded its visit budget (memoization regression)'); + // Resolve an identifier-ALIAS spine (`const a = b; const b = c; …`) ITERATIVELY, so a chain + // of any length consumes O(1) native stack. Every declaration on the spine denotes the SAME + // value, so the completed result is recorded for all of them at once. Only a non-identifier + // initializer (a literal or a `+`-fold) recurses, under the depth cap above. + const spine: ts.Declaration[] = []; + let cur: ts.Identifier = n; + let value: string | null = null; + for (;;) { + netResolveVisits += 1; // cumulative across the whole usesOutboundNetwork traversal (test evidence) + budget.spent += 1; // per-member ceiling + if (budget.spent > NET_RESOLVE_VISIT_CAP) throw new NetResolveAbort(); // resource bound: not memoized + const decl = netUniqueConstDecl(cur, checker); + if (decl === null || decl.initializer === undefined) { + value = null; + break; + } + if (memo.has(decl)) { + value = memo.get(decl) ?? null; // completed result (string or cached null) + break; + } + if (seen.has(decl)) { + value = null; // re-entry before completion: cycle (do not cache) + break; + } + seen.add(decl); + spine.push(decl); + const init = unwrapExpr(decl.initializer); + if (ts.isIdentifier(init)) { + cur = init; // alias hop: iterate, no recursion + continue; + } + value = netResolveString(init, checker, seen, memo, budget, depth + 1); // literal / `+`-fold + break; + } + // Reached only on a NON-abort return (a thrown NetResolveAbort unwinds past this, leaving the + // aborted-path declarations UNcached). The completed value is context-independent, so caching + // it for every alias on the spine is sound. + for (const d of spine) { + seen.delete(d); + memo.set(d, value); } - const decl = netUniqueConstDecl(n, checker); - if (decl === null || decl.initializer === undefined) return null; - if (memo.has(decl)) return memo.get(decl) ?? null; // completed result (string or cached null) - if (seen.has(decl)) return null; // re-entry before completion: cycle (do not cache) - seen.add(decl); - const value = netResolveString(decl.initializer, checker, seen, memo, budget); - seen.delete(decl); - memo.set(decl, value); return value; } return null; @@ -1420,7 +1495,14 @@ const netMemberNameOf = ( memo: Map, ): string | null => { if (ts.isPropertyAccessExpression(node)) return node.name.text; - return netResolveString(node.argumentExpression, checker, new Set(), memo, { spent: 0 }); + try { + return netResolveString(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0); + } catch (error) { + // A resource-bound abort (depth/visit ceiling) is an UNRESOLVED key, not a crash: the + // independent runtime-code guard rejects a computed free-global key fail-closed. + if (error instanceof NetResolveAbort) return null; + throw error; + } }; /** @@ -5553,3 +5635,213 @@ describe('D3 host shares the NET declaration memo across member resolutions (D3- expect(usesOutboundNetwork(`const a = 'Web';\nconst ws = a + 'Socket';\nvoid new globalThis[ws]('wss://example.com/');`)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// P2 (resolver totality) — the NET static-string resolver must return a BOUNDED verdict, never +// crash or allocate an unbounded intermediate (D3-CX-POLICY-NET-BIND-TOTALITY). Two verified +// Codex P2 findings, same resolver boundary: +// A. `left + right` materialized a folded string of exponential size (`aN = aN-1 + aN-1`) while +// only ~2N declaration visits were charged — the visit cap never fired, so a tiny source +// could exhaust memory / stall CI. The fold now stops tracking (returns null) once a result +// exceeds the longest capability name, BEFORE allocating the oversized string. +// B. A long chain recursed once per hop, so Node's call stack threw an uncaught RangeError +// (~7.8k frames) far below the 200,000-visit cap. The identifier-alias spine now resolves +// ITERATIVELY (O(1) native stack; genuine long chains still resolve and reject), and a +// deterministic recursion-depth cap bounds the remaining `+`-fold recursion, returning a +// bounded unresolved (null) verdict — the runtime-code guard then rejects a computed +// free-global key fail-closed. Neither bound weakens genuine `fetch` / `WebSocket` detection. +// --------------------------------------------------------------------------- +describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-POLICY-NET-BIND-TOTALITY)', () => { + const doublingKey = (n: number, base: string): string => { + const lines = [`const a0 = '${base}';`]; + for (let i = 1; i <= n; i++) lines.push(`const a${String(i)} = a${String(i - 1)} + a${String(i - 1)};`); + return lines.join('\n'); + }; + const linearKey = (n: number, base: string): string => { + const lines = [`const a0 = '${base}';`]; + for (let i = 1; i <= n; i++) lines.push(`const a${String(i)} = a${String(i - 1)};`); + return lines.join('\n'); + }; + + // --- FINDING A — output-length bound --------------------------------------------------------- + // A shallow doubling chain whose resolved length is exponential in N: the repaired resolver does + // BOUNDED work (no OOM, no hang) and never materializes the 2^N string. Under the OLD resolver + // this N=30 source built a >1 GB string on ~60 visits. `netResolveVisits` staying tiny is the + // structural proof that resolution stopped early rather than folding the whole tree. + it('does bounded work on an exponentially-growing doubling chain (no huge allocation)', () => { + const src = `${doublingKey(30, 'x')}\nvoid globalThis[a30];`; + expect(usesOutboundNetwork(src)).toBe(false); // the resolved value is not a network member + expect(netResolveVisits).toBeGreaterThan(0); + expect(netResolveVisits).toBeLessThan(1000); // linear in N, not 2^N + }, 4000); + + it('does bounded work on the reported doubling family N=40 (a0 = fe)', () => { + const src = `${doublingKey(40, 'fe')}\nvoid globalThis[a40];`; + expect(usesOutboundNetwork(src)).toBe(false); + expect(netResolveVisits).toBeLessThan(1000); + }, 4000); + + // The length bound stops tracking a value that can no longer equal a capability name, but never + // rejects a genuine short fold: every `fetch` / `WebSocket` fold (and every prefix of one) is + // within MAX_NETWORK_MEMBER_LENGTH, for direct, multi-part, and const-chain forms. + const genuineFolds: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a two-part 'fe' + 'tch'", source: `globalThis['fe' + 'tch']('https://example.com/');` }, + { form: "a five-part 'f'+'e'+'t'+'c'+'h'", source: `globalThis['f' + 'e' + 't' + 'c' + 'h']('https://example.com/');` }, + { form: "a two-part 'Web' + 'Socket'", source: `void new globalThis['Web' + 'Socket']('wss://example.com/');` }, + { form: "a nine-part W+e+b+S+o+c+k+e+t", source: `void new globalThis['W' + 'e' + 'b' + 'S' + 'o' + 'c' + 'k' + 'e' + 't']('wss://example.com/');` }, + { form: 'a genuine three-hop const chain', source: `const a = 'fetch';\nconst b = a;\nconst key = b;\nglobalThis[key]('https://example.com/');` }, + ]; + for (const { form, source } of genuineFolds) { + it(`still REJECTS ${form} under the length bound`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // A benign computed member that exceeds the capability-name length is still allowed (a static + // literal key is read directly, not folded, so it is unaffected by the fold-length bound). + it('still ALLOWS a benign over-length computed member', () => { + expect(usesOutboundNetwork(`void globalThis['ordinaryLocalMember'];`)).toBe(false); + expect(usesOutboundNetwork(`void globalThis['con' + 'sole'];`)).toBe(false); // 'console' ≤ 9 + }); + + // --- FINDING B — recursion-depth / stack totality ------------------------------------------- + // A long linear identifier chain to a genuine capability resolves ITERATIVELY: it is still + // REJECTED (not demoted to null, not a RangeError) at depths that crashed the old resolver. + for (const N of [500, 2000, 10000]) { + it(`still REJECTS a genuine linear fetch chain N=${String(N)} (iterative, no stack overflow)`, () => { + expect(usesOutboundNetwork(`${linearKey(N, 'fetch')}\nglobalThis[a${String(N)}]('https://example.com/');`)).toBe(true); + }, 8000); + it(`still REJECTS a genuine linear WebSocket chain N=${String(N)}`, () => { + expect(usesOutboundNetwork(`${linearKey(N, 'WebSocket')}\nvoid new globalThis[a${String(N)}]('wss://example.com/');`)).toBe(true); + }, 8000); + } + + // A long linear chain to a benign value is allowed without crashing. + it('ALLOWS a long linear chain to a benign value without a stack overflow', () => { + expect(usesOutboundNetwork(`${linearKey(10000, 'safe')}\nvoid globalThis[a10000];`)).toBe(false); + }, 8000); + + // A chain deep enough to exceed the recursion-depth cap yields a BOUNDED unresolved verdict + // (no RangeError, no hang). The identifier-indirected empty-base doubling reaches the `+`-fold + // recursion cap; the resolved value is not a capability, so the source is simply allowed. + for (const N of [1200, 2000, 4000]) { + it(`returns a bounded verdict past the depth cap (empty-base doubling N=${String(N)})`, () => { + expect(usesOutboundNetwork(`${doublingKey(N, '')}\nvoid globalThis[a${String(N)}];`)).toBe(false); + }, 8000); + } + + // --- VISIT-BUDGET throw audit --------------------------------------------------------------- + // The visit-budget ceiling now ABORTS to a bounded null (caught at the member boundary) instead + // of throwing an uncaught Error; a resolution well within budget is unaffected. + it('resolves within the visit budget without throwing', () => { + expect(() => usesOutboundNetwork(`${linearKey(4000, 'fetch')}\nglobalThis[a4000]('https://example.com/');`)).not.toThrow(); + }, 8000); + + // --- INTERACTION MATRIX (both bounds compose) ----------------------------------------------- + it('1. shallow but exponentially-growing string → bounded, allowed', () => { + expect(usesOutboundNetwork(`${doublingKey(30, 'x')}\nvoid globalThis[a30];`)).toBe(false); + }, 4000); + it('2. deep but constant-size string → bounded, allowed', () => { + expect(usesOutboundNetwork(`${linearKey(8000, 'safe')}\nvoid globalThis[a8000];`)).toBe(false); + }, 8000); + it('3. deep AND growing string → bounded, allowed', () => { + expect(usesOutboundNetwork(`${doublingKey(4000, 'x')}\nvoid globalThis[a4000];`)).toBe(false); + }, 8000); + it('4. depth-limit path after a cached valid result (genuine still rejected)', () => { + // genuine short chain first (caches a valid result), then a deep chain in the same source. + const src = `const good = 'fetch';\n${doublingKey(2000, '')}\nglobalThis[good]('https://example.com/');\nvoid globalThis[a2000];`; + expect(usesOutboundNetwork(src)).toBe(true); // genuine 'good' detected; deep chain is bounded null + }, 8000); + it('5. length-limit path after a cached valid result (genuine still rejected)', () => { + const src = `const good = 'fetch';\n${doublingKey(30, 'x')}\nvoid globalThis[a30];\nglobalThis[good]('https://example.com/');`; + expect(usesOutboundNetwork(src)).toBe(true); + }, 4000); + it('6. valid fetch after an earlier null (separate calls, shared nothing)', () => { + expect(usesOutboundNetwork(`${doublingKey(30, 'x')}\nvoid globalThis[a30];`)).toBe(false); + expect(usesOutboundNetwork(`globalThis['fetch']('https://example.com/');`)).toBe(true); + }, 4000); + it('7. valid WebSocket after an earlier null', () => { + expect(usesOutboundNetwork(`${linearKey(5000, 'safe')}\nvoid globalThis[a5000];`)).toBe(false); + expect(usesOutboundNetwork(`void new globalThis['Web' + 'Socket']('wss://example.com/');`)).toBe(true); + }, 8000); + it('8. null after a valid chain (both in one source, genuine rejected)', () => { + const src = `const key = 'fetch';\n${doublingKey(30, 'x')}\nglobalThis[key]('https://example.com/');\nvoid globalThis[a30];`; + expect(usesOutboundNetwork(src)).toBe(true); + }, 4000); + it('9. repeated bounded-null key reuse stays bounded and allowed', () => { + const lines = [doublingKey(30, 'x')]; + for (let j = 0; j < 50; j++) lines.push(`void globalThis[a30];`); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); + expect(netResolveVisits).toBeLessThan(1000); // shared memo: the bounded-null chain resolved once + }, 4000); + it('10. shared traversal memo after a bounded-null result does not poison a genuine key', () => { + const src = `${doublingKey(30, 'x')}\nconst good = 'fetch';\nvoid globalThis[a30];\nglobalThis[good]('https://example.com/');`; + expect(usesOutboundNetwork(src)).toBe(true); + }, 4000); + + // --- MEMO SAFETY — a resource-bound abort is NOT cached (context-dependent) ------------------ + // Mandatory adversarial shape: a genuine declaration reached once past the depth cap (aborted, + // not cached) must still resolve when reached directly from a shallow path in the SAME traversal. + // A genuine `good = 'fetch'` sits alongside a depth-exceeding chain; `good` is detected directly. + it('does not cache a depth-bound abort as a completed null for a shared declaration', () => { + const src = [ + doublingKey(2500, ''), // exceeds the depth cap → NetResolveAbort → not memoized + `const good = 'fetch';`, + `void globalThis[a2500];`, // aborts to a bounded null (allowed) + `globalThis[good]('https://example.com/');`, // genuine, resolved directly → rejected + ].join('\n'); + expect(usesOutboundNetwork(src)).toBe(true); + }, 8000); + + // The exact prescribed adversarial shape: a genuine `shared = 'fetch'` reached PAST the depth + // cap on a deep `+`-nested path (`nK = '' + nK-1`, which aborts and is NOT memoized) must still + // be detected when reached DIRECTLY from a shallow path in the SAME traversal (shared memo). + it('resolves a shared genuine decl directly after a deep-path abort left it uncached', () => { + const lines = [`const shared = 'fetch';`, `const n0 = shared;`]; + for (let i = 1; i <= 1500; i++) lines.push(`const n${String(i)} = '' + n${String(i - 1)};`); + lines.push(`void globalThis[n1500];`); // deep path → NetResolveAbort → bounded null, not cached + lines.push(`globalThis[shared]('https://example.com/');`); // shallow direct → 'fetch' → rejected + expect(usesOutboundNetwork(lines.join('\n'))).toBe(true); + }, 8000); + + // Two distinct declarations sharing a bounded-null shape each resolve independently; a genuine + // one alongside a bounded-null one is still rejected (no cross-contamination via the shared memo). + it('keeps a bounded-null chain and a genuine chain independent within one source', () => { + const src = `${doublingKey(30, 'x')}\nconst g = 'Web';\nconst ws = g + 'Socket';\nvoid globalThis[a30];\nvoid new globalThis[ws]('wss://example.com/');`; + expect(usesOutboundNetwork(src)).toBe(true); + }, 4000); + + // --- FAIL-CLOSED composition ---------------------------------------------------------------- + // A computed free-global key the NET path cannot bound remains rejected fail-closed by the + // independent runtime-code guard (unchanged): ambient, mutated, and undeclared keys. + const failClosed: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ambient declare const key', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, + { form: 'a mutated let key', source: `let k = 'fetch';\nk = 'other';\nglobalThis[k]('https://example.com/');` }, + { form: 'an undeclared free-global key', source: `void globalThis[neverDeclared];` }, + ]; + for (const { form, source } of failClosed) { + it(`keeps ${form} out of NET but rejected by the runtime-code guard`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // --- CYCLE preservation under the new spine/bounds ------------------------------------------ + const cycles: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a self cycle const a = a', source: `const a = a;\nglobalThis[a]('https://example.com/');` }, + { form: 'a 2-node cycle a = b, b = a', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');` }, + { form: 'a 3-node cycle a = b, b = c, c = a', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');` }, + ]; + for (const { form, source } of cycles) { + it(`terminates ${form} and fails closed`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // --- POISONED-BINDING allow preserved (prior fixes intact) ---------------------------------- + it('preserves the poisoned-binding ALLOW cases', () => { + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(false); + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(false); + }); +}); From 2f887efd45a5c19fe03eed3402dc4d7045d8c8b3 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 18:31:10 +0200 Subject: [PATCH 16/35] test(cockpit): fail closed on NET resolver aborts --- tests/cockpit-host/purity.test.ts | 451 ++++++++++++++++-------------- 1 file changed, 246 insertions(+), 205 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index f4181c9..64c2c2e 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1372,92 +1372,105 @@ const netUniqueConstDecl = (id: ts.Identifier, checker: ts.TypeChecker): ts.Vari // above anything real host/Cockpit source or any genuine const chain produces. const NET_RESOLVE_VISIT_CAP = 200_000; -// RESOLVER-TOTALITY BOUNDS (D3-CX-POLICY-NET-BIND-TOTALITY). Memoization bounds the NUMBER of -// declaration visits, but two independent quantities were unbounded, each turning a small valid -// source into a crash/OOM rather than a bounded policy verdict (verified Codex P2 pair): +// NET member-key CLASSIFICATION (D3-CX-POLICY-NET-KEY, frozen DDR). At a binder-verified FREE +// global receiver the computed member key is resolved to one of three states, and ONLY these three +// — a member name is never demoted to a bare `null` that silently means "allow": // -// 1. OUTPUT LENGTH. `left + right` materialized the folded string with no size bound, so a -// doubling chain (`a0 = 'x'; aN = aN-1 + aN-1`) built a 2^N-char string on ~2N visits — -// the visit cap never fired. Because `+` only ADDS characters, a (sub)result longer than -// the longest capability name can never become a NETWORK_GLOBAL_NAMES member through -// further concatenation, so the fold stops tracking it and returns `null` BEFORE allocating -// the oversized string. This is a SEMANTIC result (a declaration's resolved length is -// intrinsic to its own initializer, independent of the caller), hence safely memoizable. +// Resolved(string) — the key statically denotes exactly this string (a literal, a +// substitution-free template, a bounded `+`-fold, or a binder-proven unique +// `const` chain). DENY iff the string is a NETWORK_GLOBAL_NAMES member. +// NotCapability — the key is PROVABLY not a capability name: a `+`-fold whose result exceeds +// the longest capability name. Because `+` only ADDS characters, no further +// concatenation can shrink it to `fetch`/`WebSocket`, so this is a sound +// ALLOW — and it lets the fold stop BEFORE materializing the oversized string. +// Indeterminate — the key cannot be statically pinned down: a runtime/ambient/mutated/ +// duplicate/undeclared/shadowed-differently identifier, an initializer cycle, +// a non-string expression form, OR a RESOURCE-BOUND ABORT (depth/visit +// ceiling). At a free-global receiver this is DENIED fail-closed — a computed +// key that MIGHT be `fetch`/`WebSocket` at runtime must not slip past NET by +// being unresolvable. (Converting an abort to "allow" was the P1 egress hole: +// a deep `const shared='fetch'; nK='' + nK-1; globalThis[nN](...)` aborted and +// was allowed.) NET no longer relies on the runtime-code guard to catch these. // -// 2. RECURSION DEPTH. A long chain recursed once per hop, so Node's native call stack threw -// an uncaught `RangeError` (~7.8k frames) far below the visit cap. The identifier-ALIAS -// spine (`const a = b; const b = c; …`) is now resolved ITERATIVELY — a chain of any length -// consumes O(1) native stack, so a genuine long chain to `fetch`/`WebSocket` still resolves -// and is still rejected (rather than crashing, or worse, being demoted to `null` and slipping -// past NET). Only a non-identifier initializer (a literal or `+`-fold) recurses, and a hard -// `NET_RESOLVE_DEPTH_CAP` — deterministic, far below the native stack limit and far above any -// real host/Cockpit source or genuine capability fold — bounds that remaining recursion. -// Exceeding it is a RESOURCE-BOUND ABORT: unlike the length bound it is context-DEPENDENT -// (it depends on the depth from which a declaration was reached), so it is signalled by -// `NetResolveAbort` and NEVER written to `memo` — a declaration aborted on a deep path still -// resolves normally when later reached from a shallow one. The visit-budget ceiling now -// aborts the same way instead of throwing an uncaught `Error`. -// -// `netMemberNameOf` catches `NetResolveAbort` and folds it to `null`; the independent runtime-code -// guard then rejects a computed free-global key fail-closed, so a resource bound narrows NET -// WITHOUT opening an outbound-network bypass. (A deeply nested LITERAL `+` expression overflows -// the shared `ts.forEachChild` AST walk that every detector in this file uses, before this +// The identifier-ALIAS spine (`const a = b; const b = c; …`) resolves ITERATIVELY, so a genuine +// long chain to `fetch`/`WebSocket` still classifies as Resolved (DENY), and a long benign chain as +// Resolved-non-capability (ALLOW), rather than aborting into a false positive. Only a non-identifier +// initializer (literal / `+`-fold) recurses, bounded by `NET_RESOLVE_DEPTH_CAP` (deterministic, far +// below the native stack limit and far above any real host/Cockpit source or genuine capability +// fold); the visit ceiling bounds total work. Both bounds raise `NetResolveAbort`, which maps to +// Indeterminate. Resolved and NotCapability are context-INDEPENDENT (intrinsic to a declaration's +// own initializer) and are memoized; the abort is context-DEPENDENT (it depends on the depth a +// declaration is reached from) and is thrown, so it is NEVER memoized — a declaration aborted on a +// deep path still resolves when later reached from a shallow one. (A deeply nested LITERAL `+` +// expression overflows the shared `ts.forEachChild` AST walk every detector uses, before this // resolver is reached — a pre-existing whole-file traversal limit, out of scope here.) const NET_RESOLVE_DEPTH_CAP = 2_000; // The longest network-capability name (`WebSocket` = 9). Derived from the policy set so it stays -// correct if the set changes; a fold whose result would exceed it cannot equal any member name. +// correct if the set changes; a `+`-fold whose result exceeds it is provably NotCapability. const MAX_NETWORK_MEMBER_LENGTH = Math.max(...[...NETWORK_GLOBAL_NAMES].map((name) => name.length)); let netResolveVisits = 0; -// A resource-bound abort (recursion depth or visit budget) — DISTINCT from a semantic `null`. -// Thrown (not returned) so no partially-resolved declaration on the aborted path is memoized, and -// caught at the member boundary where it becomes a bounded `null` verdict rather than a crash. +// The three-state key classification. `Resolved` carries the exact string; the other two are +// nullary. A member key is exactly one of these — never an ambiguous `null`. +type NetKey = + | { readonly kind: 'resolved'; readonly value: string } + | { readonly kind: 'notCapability' } + | { readonly kind: 'indeterminate' }; +const NET_NOT_CAPABILITY: NetKey = { kind: 'notCapability' }; +const NET_INDETERMINATE: NetKey = { kind: 'indeterminate' }; + +// A resource-bound abort (recursion depth or visit budget). Thrown (not returned) so no partially +// resolved declaration on the aborted path is memoized, and caught at the member boundary where it +// becomes an Indeterminate key (fail-closed DENY at a free-global receiver), never a crash. class NetResolveAbort extends Error {} -const netResolveString = ( +const netResolveKey = ( node: ts.Expression, checker: ts.TypeChecker, seen: Set, - memo: Map, + memo: Map, budget: { spent: number }, depth: number, -): string | null => { +): NetKey => { if (depth > NET_RESOLVE_DEPTH_CAP) throw new NetResolveAbort(); // resource bound: not memoized const n = unwrapExpr(node); - if (ts.isStringLiteralLike(n)) return n.text; + if (ts.isStringLiteralLike(n)) return { kind: 'resolved', value: n.text }; if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = netResolveString(n.left, checker, seen, memo, budget, depth + 1); - if (left === null) return null; - const right = netResolveString(n.right, checker, seen, memo, budget, depth + 1); - if (right === null) return null; - // Bound OUTPUT before allocating `left + right`: an oversized result can never equal a - // capability name, so stop tracking it — no exponential intermediate is ever materialized. - if (left.length + right.length > MAX_NETWORK_MEMBER_LENGTH) return null; - return left + right; + const left = netResolveKey(n.left, checker, seen, memo, budget, depth + 1); + if (left.kind === 'indeterminate') return NET_INDETERMINATE; + if (left.kind === 'notCapability') return NET_NOT_CAPABILITY; // already too long; `+` only grows it + const right = netResolveKey(n.right, checker, seen, memo, budget, depth + 1); + if (right.kind === 'indeterminate') return NET_INDETERMINATE; + if (right.kind === 'notCapability') return NET_NOT_CAPABILITY; + // Bound OUTPUT before allocating `left + right`: an oversized result is provably NotCapability, + // so no exponential intermediate is ever materialized. + if (left.value.length + right.value.length > MAX_NETWORK_MEMBER_LENGTH) return NET_NOT_CAPABILITY; + return { kind: 'resolved', value: left.value + right.value }; } if (ts.isIdentifier(n)) { - // Resolve an identifier-ALIAS spine (`const a = b; const b = c; …`) ITERATIVELY, so a chain - // of any length consumes O(1) native stack. Every declaration on the spine denotes the SAME - // value, so the completed result is recorded for all of them at once. Only a non-identifier - // initializer (a literal or a `+`-fold) recurses, under the depth cap above. + // Resolve an identifier-ALIAS spine (`const a = b; const b = c; …`) ITERATIVELY, so a chain of + // any length consumes O(1) native stack. Every declaration on the spine denotes the SAME key, so + // the completed classification is recorded for all of them at once. A hop that is not a + // binder-proven unique `const` (runtime/ambient/mutated/duplicate/undeclared/shadowed) or a + // cycle is Indeterminate. Only a non-identifier initializer (literal / `+`-fold) recurses. const spine: ts.Declaration[] = []; let cur: ts.Identifier = n; - let value: string | null = null; + let key: NetKey = NET_INDETERMINATE; for (;;) { netResolveVisits += 1; // cumulative across the whole usesOutboundNetwork traversal (test evidence) budget.spent += 1; // per-member ceiling if (budget.spent > NET_RESOLVE_VISIT_CAP) throw new NetResolveAbort(); // resource bound: not memoized const decl = netUniqueConstDecl(cur, checker); if (decl === null || decl.initializer === undefined) { - value = null; + key = NET_INDETERMINATE; // not a binder-proven unique const → unknown key break; } if (memo.has(decl)) { - value = memo.get(decl) ?? null; // completed result (string or cached null) + key = memo.get(decl) ?? NET_INDETERMINATE; // completed classification break; } if (seen.has(decl)) { - value = null; // re-entry before completion: cycle (do not cache) + key = NET_INDETERMINATE; // re-entry before completion: cycle (do not cache) break; } seen.add(decl); @@ -1467,40 +1480,38 @@ const netResolveString = ( cur = init; // alias hop: iterate, no recursion continue; } - value = netResolveString(init, checker, seen, memo, budget, depth + 1); // literal / `+`-fold + key = netResolveKey(init, checker, seen, memo, budget, depth + 1); // literal / `+`-fold break; } // Reached only on a NON-abort return (a thrown NetResolveAbort unwinds past this, leaving the - // aborted-path declarations UNcached). The completed value is context-independent, so caching - // it for every alias on the spine is sound. + // aborted-path declarations UNcached). The completed classification is context-independent, so + // caching it for every alias on the spine is sound. for (const d of spine) { seen.delete(d); - memo.set(d, value); + memo.set(d, key); } - return value; + return key; } - return null; + return NET_INDETERMINATE; // any other expression form (call, number, conditional, …): unknown key }; -// `memberNameOf` for the NET path: a property name is read directly; an element-access key is -// resolved by `netResolveString` (binder identity at every hop), never by identifier text alone. -// The completed-result `memo` is SHARED across every member resolution of one `usesOutboundNetwork` -// traversal, so a const chain reused by many accesses is resolved once (O(N + M), not O(M × N)). A -// fresh `seen`/`budget` per call keeps active-path cycle detection and the per-member cap local: a -// completed result is context-independent, so sharing it is sound, whereas sharing in-progress path -// state would not be. -const netMemberNameOf = ( +// Classify the member key of a property/element access for the NET path. A property name is read +// directly (always Resolved); an element-access key is resolved by `netResolveKey` (binder identity +// at every hop), never by identifier text alone. The completed-classification `memo` is SHARED +// across every member resolution of one `usesOutboundNetwork` traversal, so a const chain reused by +// many accesses is resolved once (O(N + M), not O(M × N)); a fresh `seen`/`budget` per call keeps +// active-path cycle detection and the per-member ceiling local. A resource-bound abort becomes +// Indeterminate (fail-closed at a free-global receiver). +const netMemberKey = ( node: ts.PropertyAccessExpression | ts.ElementAccessExpression, checker: ts.TypeChecker, - memo: Map, -): string | null => { - if (ts.isPropertyAccessExpression(node)) return node.name.text; + memo: Map, +): NetKey => { + if (ts.isPropertyAccessExpression(node)) return { kind: 'resolved', value: node.name.text }; try { - return netResolveString(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0); + return netResolveKey(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0); } catch (error) { - // A resource-bound abort (depth/visit ceiling) is an UNRESOLVED key, not a crash: the - // independent runtime-code guard rejects a computed free-global key fail-closed. - if (error instanceof NetResolveAbort) return null; + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; throw error; } }; @@ -1520,16 +1531,18 @@ const netMemberNameOf = ( */ const usesOutboundNetwork = (source: string): boolean => { const { checker, sourceFile } = buildBinderProgram(source); - // Free-global network members (F1/P2) are resolved by `netMemberNameOf`, which reads string - // constants straight off the binder — identity required at the key AND at every initializer hop - // (see `netResolveString`) — rather than through the text-keyed const map. A key that does not - // statically resolve stays `null` and remains rejected fail-closed by the runtime-code guard. + // Free-global network members (F1/P2/P1) are classified by `netMemberKey` straight off the binder + // — identity required at the key AND at every initializer hop (see `netResolveKey`) — into + // Resolved / NotCapability / Indeterminate. At a binder-verified free-global receiver: + // Resolved(`fetch`/`WebSocket`) and Indeterminate DENY; Resolved(other) and NotCapability ALLOW. + // NET is self-contained fail-closed here — it does NOT lean on the runtime-code guard to reject an + // indeterminate computed key. // - // ONE completed-result memo is shared by every member resolution in THIS traversal, so a const - // chain reused across many accesses is resolved once (O(N + M), not O(M × N)). It is keyed by the - // binder's declaration nodes for this Program, so it cannot leak into another `usesOutboundNetwork` - // call. `netResolveVisits` is reset here to make the cumulative hop count observable to tests. - const netMemo = new Map(); + // ONE completed-classification memo is shared by every member resolution in THIS traversal, so a + // const chain reused across many accesses is resolved once (O(N + M), not O(M × N)). It is keyed by + // the binder's declaration nodes for this Program, so it cannot leak into another + // `usesOutboundNetwork` call. `netResolveVisits` is reset here to make the hop count observable. + const netMemo = new Map(); netResolveVisits = 0; let found = false; const visit = (node: ts.Node): void => { @@ -1542,17 +1555,18 @@ const usesOutboundNetwork = (source: string): boolean => { const member = binderMemberName(node); if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; } - // F1/P2 — a free-global network member (`fetch`/`WebSocket`) resolved by `netMemberNameOf` - // under RECURSIVE binder identity: a direct literal, a `+`-fold (`'fe' + 'tch'`), or a - // unique immutable `const` chain (`const a = 'fetch'; const key = a`) whose key AND every - // initializer identifier the binder proves denote the collected const. A key — or any - // initializer identifier in its chain — that resolves to a different same-text binding - // (or to no in-file binding, e.g. `const key = Infinity` folding an out-of-scope - // `const Infinity = 'fetch'`) is NOT substituted; it stays `null` here. A genuinely - // indeterminate key likewise resolves to `null` (the runtime-code guard rejects it). - const globalMember = netMemberNameOf(node, checker, netMemo); - if (globalMember !== null && NETWORK_GLOBAL_NAMES.has(globalMember) && isFreeGlobalReceiver(node.expression, checker, sourceFile)) { - found = true; + // F1/P2/P1 — classify the member key (Resolved / NotCapability / Indeterminate) off the binder + // and decide it ONLY at a binder-verified free-global receiver. Resolved(capability) DENY; + // Resolved(other) ALLOW; NotCapability (provably too long) ALLOW; Indeterminate (runtime/ + // ambient/mutated/undeclared/cycle key, or a depth/visit resource abort) DENY fail-closed — + // a key that MIGHT be `fetch`/`WebSocket` at runtime must not pass by being unresolvable. + const memberKey = netMemberKey(node, checker, netMemo); + if (isFreeGlobalReceiver(node.expression, checker, sourceFile)) { + if (memberKey.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(memberKey.value)) found = true; + } else if (memberKey.kind === 'indeterminate') { + found = true; + } } } // (2) an identifier value read: HTTP_CLIENT is forbidden; HTTP_NS must sit in a safe @@ -5189,7 +5203,7 @@ describe('D3 host inspects destructuring assignments for socket acquisition (D3- // The check now resolves the member through the existing `collectStringConsts` / `memberNameOf` // static-string machinery (no new evaluator), so the whole NETWORK_GLOBAL_NAMES family is caught // for direct, `+`-folded, and unique-immutable-const keys. A genuinely indeterminate key is not -// resolved here and remains rejected fail-closed by the runtime-code guard. +// resolved here and is DENIED fail-closed by NET at a free-global receiver. // --------------------------------------------------------------------------- describe('D3 host rejects statically-computed free-global network members (D3-CX-CODEX-F1-COMPUTED)', () => { const rejectComputed: readonly { readonly form: string; readonly source: string }[] = [ @@ -5214,17 +5228,16 @@ describe('D3 host rejects statically-computed free-global network members (D3-CX }); } - // A genuinely indeterminate free-global member key is NOT resolved by the network branch (it - // stays out of scope for this static check) but REMAINS rejected fail-closed by the runtime- - // code guard — the overall purity enforcement still rejects it. This proves no weakening. + // A genuinely indeterminate free-global member key (a runtime `declare const` key) cannot be + // statically pinned down, so at a free-global receiver NET DENIES it fail-closed (frozen key + // policy) — a key that might be `fetch`/`WebSocket` at runtime must not pass by being unresolvable. const indeterminate: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a runtime-key globalThis[k] call', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, { form: 'a runtime-key new globalThis[k]', source: `declare const k: string;\nvoid new globalThis[k]('wss://example.com/');` }, ]; for (const { form, source } of indeterminate) { - it(`keeps ${form} rejected by the runtime-code guard`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`rejects ${form} fail-closed`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } @@ -5253,21 +5266,24 @@ describe('D3 host rejects statically-computed free-global network members (D3-CX // substitutes a collected constant for an Identifier key ONLY when the compiler binder proves // the reference denotes that same unique `const` declaration; identifier text equality is not // binding identity. Literals and `+`-folds are unchanged, a genuine same-binding const key is -// still rejected, and a key that cannot be bound stays out of scope for NET and remains rejected -// fail-closed by the independent runtime-code guard. RC/HA text-only policy is untouched. +// still rejected, and a key that cannot be bound is Indeterminate and is DENIED fail-closed by NET +// itself at a free-global receiver. RC/HA text-only policy is untouched. // --------------------------------------------------------------------------- describe('D3 host resolves computed network-member keys by binder identity (D3-CX-POLICY-NET-BIND)', () => { - // The verified reproducer is a genuine FALSE POSITIVE: NET no longer flags it AND the - // runtime-code guard does not either — the source is truly accepted, not merely shifted. - it('accepts the verified reproducer (out-of-scope const Infinity) by both guards', () => { + // Under the frozen fail-closed key policy the outer `globalThis[Infinity]` key is Indeterminate + // (it does not resolve to a unique in-file `const`), so NET DENIES it — a computed free-global key + // that cannot be statically pinned down is not allowed. Binder identity is still authoritative for + // genuine same-symbol chains (which stay REJECT below) and for Resolved non-capability keys. + it('rejects the indeterminate reproducer (out-of-scope const Infinity) fail-closed', () => { const reproducer = `function f() {\n const Infinity = 'fetch';\n void Infinity;\n}\nvoid (globalThis as any)[Infinity];`; - expect(usesOutboundNetwork(reproducer)).toBe(false); - expect(usesRuntimeCodeGeneration(reproducer)).toBe(false); + expect(usesOutboundNetwork(reproducer)).toBe(true); }); - // ALLOW — the collected `const = ''` exists (unique in the file) but the - // element-access key reference does NOT lexically resolve to it, so no substitution is proven. - const allowDifferentBinding: readonly { readonly form: string; readonly source: string }[] = [ + // DENY fail-closed — the element-access key does NOT lexically resolve to a unique in-file `const` + // (a different/inner/sibling scope, a parameter or import shadow, or a free global), so it is + // Indeterminate and, at a free-global receiver, denied. None is a proven capability chain, but + // none is provably NOT one either, so fail-closed is the sound verdict. + const indeterminateKeys: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a function-scoped const Infinity with an outer key reference', source: `function f() {\n const Infinity = 'fetch';\n void Infinity;\n}\nvoid (globalThis as any)[Infinity];`, @@ -5293,9 +5309,9 @@ describe('D3 host resolves computed network-member keys by binder identity (D3-C source: `import { helper } from './x.js';\nfunction f() {\n const helper = 'fetch';\n void helper;\n}\nvoid helper;\nvoid globalThis[helper];`, }, ]; - for (const { form, source } of allowDifferentBinding) { - it(`ALLOWS ${form}`, () => { - expect(usesOutboundNetwork(source)).toBe(false); + for (const { form, source } of indeterminateKeys) { + it(`rejects ${form} fail-closed`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } @@ -5316,18 +5332,17 @@ describe('D3 host resolves computed network-member keys by binder identity (D3-C }); } - // FAIL-CLOSED — a key the NET branch cannot bind (ambient, mutated, or undeclared) is not - // flagged by NET but REMAINS rejected fail-closed by the runtime-code guard. Binder-identity - // gating narrows NET substitution WITHOUT opening an outbound-network bypass. + // FAIL-CLOSED — a key the NET branch cannot bind (ambient, mutated, or undeclared) is + // Indeterminate and is DENIED by NET itself at a free-global receiver (no longer deferred to the + // runtime-code guard), so binder-identity gating narrows NET WITHOUT opening an egress bypass. const failClosed: readonly { readonly form: string; readonly source: string }[] = [ { form: 'an ambient declare const key globalThis[k]', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, { form: 'a mutated let key globalThis[k]', source: `let k = 'fetch';\nk = 'other';\nglobalThis[k]('https://example.com/');` }, { form: 'an undeclared free-global key globalThis[neverDeclared]', source: `void globalThis[neverDeclared];` }, ]; for (const { form, source } of failClosed) { - it(`keeps ${form} out of NET but rejected by the runtime-code guard`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`rejects ${form} fail-closed by NET`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } }); @@ -5342,20 +5357,20 @@ describe('D3 host resolves computed network-member keys by binder identity (D3-C // identifier reached while resolving a collected initializer — must resolve to the unique `const` // declaration whose value is being substituted, recursively, bounded against initializer cycles. // A genuine multi-hop same-symbol chain still folds and is still rejected; a chain poisoned at any -// hop demotes to unresolved and is left to the fail-closed runtime-code guard. +// hop is Indeterminate and is DENIED fail-closed by NET itself at a free-global receiver. // --------------------------------------------------------------------------- describe('D3 host binds identifiers inside collected constant initializers (D3-CX-POLICY-NET-BIND-INIT)', () => { - // The verified reproducer is a genuine FALSE POSITIVE: NET no longer flags it AND the - // runtime-code guard does not either — the source is truly accepted, not merely shifted. - it('accepts the verified reproducer (poisoned initializer const key = Infinity) by both guards', () => { + // Under the frozen fail-closed key policy: `const key = Infinity` where `Infinity` does not + // resolve to a unique in-file `const` leaves the key Indeterminate, so NET DENIES it. (Binder + // identity still folds a genuine same-symbol multi-hop chain to Resolved(capability) — REJECT.) + it('rejects the poisoned-initializer reproducer (const key = Infinity) fail-closed', () => { const reproducer = `function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`; - expect(usesOutboundNetwork(reproducer)).toBe(false); - expect(usesRuntimeCodeGeneration(reproducer)).toBe(false); + expect(usesOutboundNetwork(reproducer)).toBe(true); }); - // ALLOW — the key is a genuine unique const, but an identifier INSIDE its initializer chain does - // not lexically resolve to the collected declaration, so the fold is not binder-proven. - const allowPoisonedInit: readonly { readonly form: string; readonly source: string }[] = [ + // DENY fail-closed — the key is a unique const, but an identifier INSIDE its initializer chain + // does not resolve to a unique in-file const, so the chain is Indeterminate (not binder-proven). + const indeterminateInit: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a function-scoped const Infinity folded into a module const initializer', source: `function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`, @@ -5389,9 +5404,9 @@ describe('D3 host binds identifiers inside collected constant initializers (D3-C source: `function a() {\n const Infinity = 'fetch';\n void Infinity;\n}\nfunction b() {\n const key = Infinity;\n void globalThis[key];\n}\nvoid a;\nvoid b;`, }, ]; - for (const { form, source } of allowPoisonedInit) { - it(`ALLOWS ${form}`, () => { - expect(usesOutboundNetwork(source)).toBe(false); + for (const { form, source } of indeterminateInit) { + it(`rejects ${form} fail-closed`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } @@ -5413,17 +5428,15 @@ describe('D3 host binds identifiers inside collected constant initializers (D3-C } // FAIL-CLOSED — a chain the NET path cannot binder-prove (ambient initializer, or a cycle) is - // not flagged by NET but REMAINS rejected fail-closed by the runtime-code guard, and cycle - // resolution TERMINATES. No outbound-network bypass is opened. + // Indeterminate and is DENIED by NET itself, and cycle resolution still TERMINATES. No bypass. const failClosedChain: readonly { readonly form: string; readonly source: string }[] = [ { form: 'an ambient-declare initializer const key = k', source: `declare const k: string;\nconst key = k;\nglobalThis[key]('https://example.com/');` }, { form: 'a two-const initializer cycle a = b, b = a', source: `const a = b;\nconst b = a;\nvoid globalThis[a];` }, { form: 'a self-referential initializer const a = a', source: `const a = a;\nvoid globalThis[a];` }, ]; for (const { form, source } of failClosedChain) { - it(`keeps ${form} out of NET but rejected by the runtime-code guard (terminates)`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`rejects ${form} fail-closed by NET (terminates)`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } }); @@ -5478,16 +5491,15 @@ describe('D3 host memoizes NET constant resolution by declaration identity (D3-C expect(usesOutboundNetwork(linear(40, 'fetch'))).toBe(true); }, 4000); - // Cycles: memo + cycle interaction terminates and stays fail-closed (NET null, RC rejects). + // Cycles: memo + cycle interaction terminates and is DENIED fail-closed by NET (Indeterminate). const cycles: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a self cycle const a = a', source: `const a = a;\nglobalThis[a]('https://example.com/');` }, { form: 'a 2-node cycle a = b, b = a', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');` }, { form: 'a 3-node cycle a = b, b = c, c = a', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');` }, ]; for (const { form, source } of cycles) { - it(`keeps ${form} out of NET but rejected fail-closed (terminates)`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`rejects ${form} fail-closed by NET (terminates)`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }, 4000); } @@ -5591,44 +5603,47 @@ describe('D3 host shares the NET declaration memo across member resolutions (D3- expect(usesOutboundNetwork(`const key = 'safe';\nvoid globalThis[key];`)).toBe(false); }); - // A failed (poisoned → null) chain and a genuine chain coexist in the shared memo without - // contaminating one another (both orders exercised by the two accesses). - it('keeps a poisoned (null) and a genuine chain independent within one source', () => { + // An Indeterminate (poisoned) chain and a genuine chain coexist in the shared memo without + // contaminating one another: both are denied at a free-global receiver, so the overall verdict is + // deny (both orders exercised by the two accesses). + it('keeps a poisoned (Indeterminate) and a genuine chain independent within one source', () => { const src = [ `function f() { const p = 'fetch'; void p; }`, - `const bad = p;`, // out-of-scope p → null → that access allowed + `const bad = p;`, // out-of-scope p → Indeterminate → denied fail-closed `const good = 'fetch';`, `void globalThis[bad];`, - `globalThis[good]('https://example.com/');`, // genuine → rejected overall + `globalThis[good]('https://example.com/');`, // genuine → rejected ].join('\n'); expect(usesOutboundNetwork(src)).toBe(true); }); - // Cached NULL is reused for a repeated poisoned key (no per-access recomputation, still allowed). - it('reuses a cached null result for a repeated poisoned key', () => { + // A cached Indeterminate classification is reused for a repeated poisoned key (no per-access + // recomputation); it is DENIED fail-closed at the free-global receiver. + it('reuses a cached Indeterminate result for a repeated poisoned key (denied)', () => { const lines = [`function f() { const marker = 'fetch'; void marker; }`, `const key = marker;`]; for (let j = 0; j < 50; j++) lines.push('void globalThis[key];'); - expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(true); expect(netResolveVisits).toBeLessThan(1000); }); - // Cycles still terminate and fail closed with the shared memo (in-progress state is never cached). + // Cycles still terminate and are DENIED fail-closed by NET with the shared memo (in-progress + // state is never cached). const cycles: readonly { readonly form: string; readonly source: string }[] = [ { form: 'self cycle', source: `const a = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[a];` }, { form: '2-node cycle', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[b];` }, { form: '3-node cycle', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');\nvoid globalThis[c];` }, ]; for (const { form, source } of cycles) { - it(`terminates a ${form} and fails closed with a shared memo`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`terminates a ${form} and is denied fail-closed with a shared memo`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } - // Prior binding fixes are preserved under the shared memo: poisoned keys allowed, genuine rejected. - it('preserves the poisoned-initializer ALLOW under the shared memo', () => { - expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(false); - expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(false); + // Under the shared memo an Indeterminate poisoned key is DENIED fail-closed, and a genuine chain + // is still rejected. + it('denies a poisoned-initializer key under the shared memo', () => { + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(true); + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(true); }); it('preserves the genuine-chain REJECT under the shared memo', () => { expect(usesOutboundNetwork(`const a = 'fetch';\nconst b = a;\nconst key = b;\nglobalThis[key]('https://example.com/');`)).toBe(true); @@ -5637,19 +5652,24 @@ describe('D3 host shares the NET declaration memo across member resolutions (D3- }); // --------------------------------------------------------------------------- -// P2 (resolver totality) — the NET static-string resolver must return a BOUNDED verdict, never -// crash or allocate an unbounded intermediate (D3-CX-POLICY-NET-BIND-TOTALITY). Two verified -// Codex P2 findings, same resolver boundary: -// A. `left + right` materialized a folded string of exponential size (`aN = aN-1 + aN-1`) while -// only ~2N declaration visits were charged — the visit cap never fired, so a tiny source -// could exhaust memory / stall CI. The fold now stops tracking (returns null) once a result -// exceeds the longest capability name, BEFORE allocating the oversized string. -// B. A long chain recursed once per hop, so Node's call stack threw an uncaught RangeError -// (~7.8k frames) far below the 200,000-visit cap. The identifier-alias spine now resolves -// ITERATIVELY (O(1) native stack; genuine long chains still resolve and reject), and a -// deterministic recursion-depth cap bounds the remaining `+`-fold recursion, returning a -// bounded unresolved (null) verdict — the runtime-code guard then rejects a computed -// free-global key fail-closed. Neither bound weakens genuine `fetch` / `WebSocket` detection. +// P2/P1 (resolver totality + fail-closed abort) — the NET member-key classifier must return a +// BOUNDED verdict, never crash or allocate an unbounded intermediate, and an abort must fail CLOSED +// (D3-CX-POLICY-NET-BIND-TOTALITY / D3-CX-POLICY-NET-KEY). Three verified Codex findings, same +// resolver boundary: +// A (P2). `left + right` materialized a folded string of exponential size (`aN = aN-1 + aN-1`) +// while only ~2N visits were charged, so the visit cap never fired. A fold whose result +// exceeds the longest capability name is now classified NotCapability BEFORE allocating the +// oversized string, and NotCapability ALLOWS (it can never equal `fetch`/`WebSocket`). +// B (P2). A long chain recursed once per hop, so Node's call stack threw an uncaught RangeError +// (~7.8k frames) far below the visit cap. The identifier-alias spine now resolves ITERATIVELY +// (O(1) native stack; genuine long chains still classify Resolved and reject, benign ones +// Resolved-non-capability and allow), and a deterministic recursion-depth cap bounds the +// remaining `+`-fold recursion. +// C (P1). Converting a resource abort to "allow" let a genuine deep fetch chain slip past NET +// (`const shared='fetch'; nK='' + nK-1; globalThis[nN](...)`). A resource abort now maps to +// Indeterminate, and Indeterminate at a free-global receiver is DENIED fail-closed — NET no +// longer relies on the runtime-code guard to catch an unresolvable computed key. +// Neither bound weakens genuine `fetch`/`WebSocket` detection. // --------------------------------------------------------------------------- describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-POLICY-NET-BIND-TOTALITY)', () => { const doublingKey = (n: number, base: string): string => { @@ -5721,18 +5741,19 @@ describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-P expect(usesOutboundNetwork(`${linearKey(10000, 'safe')}\nvoid globalThis[a10000];`)).toBe(false); }, 8000); - // A chain deep enough to exceed the recursion-depth cap yields a BOUNDED unresolved verdict - // (no RangeError, no hang). The identifier-indirected empty-base doubling reaches the `+`-fold - // recursion cap; the resolved value is not a capability, so the source is simply allowed. + // A chain deep enough to exceed the recursion-depth cap yields a BOUNDED verdict (no RangeError, + // no hang) — Indeterminate, which at a free-global receiver is DENIED fail-closed (the key could + // be a capability at runtime). The identifier-indirected empty-base doubling reaches the `+`-fold + // recursion cap and aborts. for (const N of [1200, 2000, 4000]) { - it(`returns a bounded verdict past the depth cap (empty-base doubling N=${String(N)})`, () => { - expect(usesOutboundNetwork(`${doublingKey(N, '')}\nvoid globalThis[a${String(N)}];`)).toBe(false); + it(`denies fail-closed past the depth cap (empty-base doubling N=${String(N)})`, () => { + expect(usesOutboundNetwork(`${doublingKey(N, '')}\nvoid globalThis[a${String(N)}];`)).toBe(true); }, 8000); } // --- VISIT-BUDGET throw audit --------------------------------------------------------------- - // The visit-budget ceiling now ABORTS to a bounded null (caught at the member boundary) instead - // of throwing an uncaught Error; a resolution well within budget is unaffected. + // The visit-budget ceiling now ABORTS to Indeterminate (caught at the member boundary; denied + // fail-closed) instead of throwing an uncaught Error; a resolution within budget is unaffected. it('resolves within the visit budget without throwing', () => { expect(() => usesOutboundNetwork(`${linearKey(4000, 'fetch')}\nglobalThis[a4000]('https://example.com/');`)).not.toThrow(); }, 8000); @@ -5744,13 +5765,15 @@ describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-P it('2. deep but constant-size string → bounded, allowed', () => { expect(usesOutboundNetwork(`${linearKey(8000, 'safe')}\nvoid globalThis[a8000];`)).toBe(false); }, 8000); - it('3. deep AND growing string → bounded, allowed', () => { - expect(usesOutboundNetwork(`${doublingKey(4000, 'x')}\nvoid globalThis[a4000];`)).toBe(false); + it('3. deep AND growing string → bounded, denied fail-closed (depth abort)', () => { + // Resolving a4000 recurses down to a0 (~2N depth) BEFORE the length bound can fire, so it hits + // the depth cap and aborts → Indeterminate → DENY. Bounded (no RangeError), fail-closed. + expect(usesOutboundNetwork(`${doublingKey(4000, 'x')}\nvoid globalThis[a4000];`)).toBe(true); }, 8000); it('4. depth-limit path after a cached valid result (genuine still rejected)', () => { // genuine short chain first (caches a valid result), then a deep chain in the same source. const src = `const good = 'fetch';\n${doublingKey(2000, '')}\nglobalThis[good]('https://example.com/');\nvoid globalThis[a2000];`; - expect(usesOutboundNetwork(src)).toBe(true); // genuine 'good' detected; deep chain is bounded null + expect(usesOutboundNetwork(src)).toBe(true); // genuine 'good' detected; deep chain aborts → denied }, 8000); it('5. length-limit path after a cached valid result (genuine still rejected)', () => { const src = `const good = 'fetch';\n${doublingKey(30, 'x')}\nvoid globalThis[a30];\nglobalThis[good]('https://example.com/');`; @@ -5768,26 +5791,44 @@ describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-P const src = `const key = 'fetch';\n${doublingKey(30, 'x')}\nglobalThis[key]('https://example.com/');\nvoid globalThis[a30];`; expect(usesOutboundNetwork(src)).toBe(true); }, 4000); - it('9. repeated bounded-null key reuse stays bounded and allowed', () => { + it('9. repeated NotCapability key reuse stays bounded and allowed', () => { const lines = [doublingKey(30, 'x')]; for (let j = 0; j < 50; j++) lines.push(`void globalThis[a30];`); expect(usesOutboundNetwork(lines.join('\n'))).toBe(false); - expect(netResolveVisits).toBeLessThan(1000); // shared memo: the bounded-null chain resolved once + expect(netResolveVisits).toBeLessThan(1000); // shared memo: the NotCapability chain resolved once }, 4000); - it('10. shared traversal memo after a bounded-null result does not poison a genuine key', () => { + it('10. shared traversal memo after a NotCapability result does not poison a genuine key', () => { const src = `${doublingKey(30, 'x')}\nconst good = 'fetch';\nvoid globalThis[a30];\nglobalThis[good]('https://example.com/');`; expect(usesOutboundNetwork(src)).toBe(true); }, 4000); + // --- P1 — a resource abort DENIES fail-closed (D3-CX-POLICY-NET-KEY) ------------------------- + // The exact reported reproducer: a genuine `fetch` chain that the resolver ABORTS on (its `+`-fold + // recursion exceeds the depth cap) must be DENIED, not allowed. Under the old "abort → null → + // allow" mapping this was a genuine egress false negative. + it('denies the reported depth-abort fetch chain fail-closed (P1)', () => { + const lines = [`const shared = 'fetch';`, `const n0 = shared;`]; + for (let i = 1; i <= 2500; i++) lines.push(`const n${String(i)} = '' + n${String(i - 1)};`); + lines.push(`globalThis[n2500]('https://example.com/');`); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(true); + }, 8000); + it('denies a depth-abort WebSocket chain fail-closed (P1)', () => { + const lines = [`const shared = 'WebSocket';`, `const n0 = shared;`]; + for (let i = 1; i <= 2500; i++) lines.push(`const n${String(i)} = '' + n${String(i - 1)};`); + lines.push(`void new globalThis[n2500]('wss://example.com/');`); + expect(usesOutboundNetwork(lines.join('\n'))).toBe(true); + }, 8000); + // --- MEMO SAFETY — a resource-bound abort is NOT cached (context-dependent) ------------------ - // Mandatory adversarial shape: a genuine declaration reached once past the depth cap (aborted, - // not cached) must still resolve when reached directly from a shallow path in the SAME traversal. - // A genuine `good = 'fetch'` sits alongside a depth-exceeding chain; `good` is detected directly. - it('does not cache a depth-bound abort as a completed null for a shared declaration', () => { + // Mandatory adversarial shape: a declaration reached once past the depth cap (aborted, not + // cached) must still classify correctly when reached directly from a shallow path in the SAME + // traversal. A genuine `good = 'fetch'` sits alongside a depth-exceeding chain; both DENY, and + // `good` is detected directly (the abort did not poison the shared memo). + it('does not cache a depth-bound abort for a shared declaration', () => { const src = [ - doublingKey(2500, ''), // exceeds the depth cap → NetResolveAbort → not memoized + doublingKey(2500, ''), // exceeds the depth cap → NetResolveAbort → not memoized → denied `const good = 'fetch';`, - `void globalThis[a2500];`, // aborts to a bounded null (allowed) + `void globalThis[a2500];`, // aborts → Indeterminate → denied fail-closed `globalThis[good]('https://example.com/');`, // genuine, resolved directly → rejected ].join('\n'); expect(usesOutboundNetwork(src)).toBe(true); @@ -5804,44 +5845,44 @@ describe('D3 host bounds NET resolver output growth and recursion depth (D3-CX-P expect(usesOutboundNetwork(lines.join('\n'))).toBe(true); }, 8000); - // Two distinct declarations sharing a bounded-null shape each resolve independently; a genuine - // one alongside a bounded-null one is still rejected (no cross-contamination via the shared memo). - it('keeps a bounded-null chain and a genuine chain independent within one source', () => { + // Two distinct declarations sharing a NotCapability shape each resolve independently; a genuine + // one alongside a NotCapability one is still rejected (no cross-contamination via the shared memo). + it('keeps a NotCapability chain and a genuine chain independent within one source', () => { const src = `${doublingKey(30, 'x')}\nconst g = 'Web';\nconst ws = g + 'Socket';\nvoid globalThis[a30];\nvoid new globalThis[ws]('wss://example.com/');`; expect(usesOutboundNetwork(src)).toBe(true); }, 4000); - // --- FAIL-CLOSED composition ---------------------------------------------------------------- - // A computed free-global key the NET path cannot bound remains rejected fail-closed by the - // independent runtime-code guard (unchanged): ambient, mutated, and undeclared keys. + // --- FAIL-CLOSED (NET self-contained) ------------------------------------------------------- + // A computed free-global key the NET path cannot pin down is Indeterminate and is DENIED by NET + // itself: ambient, mutated, and undeclared keys. const failClosed: readonly { readonly form: string; readonly source: string }[] = [ { form: 'an ambient declare const key', source: `declare const k: string;\nglobalThis[k]('https://example.com/');` }, { form: 'a mutated let key', source: `let k = 'fetch';\nk = 'other';\nglobalThis[k]('https://example.com/');` }, { form: 'an undeclared free-global key', source: `void globalThis[neverDeclared];` }, ]; for (const { form, source } of failClosed) { - it(`keeps ${form} out of NET but rejected by the runtime-code guard`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`rejects ${form} fail-closed by NET`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } - // --- CYCLE preservation under the new spine/bounds ------------------------------------------ + // --- CYCLE termination under the new spine/bounds (denied fail-closed) ---------------------- const cycles: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a self cycle const a = a', source: `const a = a;\nglobalThis[a]('https://example.com/');` }, { form: 'a 2-node cycle a = b, b = a', source: `const a = b;\nconst b = a;\nglobalThis[a]('https://example.com/');` }, { form: 'a 3-node cycle a = b, b = c, c = a', source: `const a = b;\nconst b = c;\nconst c = a;\nglobalThis[a]('https://example.com/');` }, ]; for (const { form, source } of cycles) { - it(`terminates ${form} and fails closed`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - expect(usesRuntimeCodeGeneration(source)).toBe(true); + it(`terminates ${form} and is denied fail-closed by NET`, () => { + expect(usesOutboundNetwork(source)).toBe(true); }); } - // --- POISONED-BINDING allow preserved (prior fixes intact) ---------------------------------- - it('preserves the poisoned-binding ALLOW cases', () => { - expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(false); - expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(false); + // --- POISONED-BINDING now DENIED fail-closed (frozen key policy supersedes the earlier allow) - + // An out-of-scope `const Infinity = 'fetch'` leaves `globalThis[Infinity]` Indeterminate, so it is + // denied — the analyzer cannot prove the runtime key is not a capability, and fail-closed wins. + it('denies the poisoned-binding cases fail-closed', () => { + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nvoid globalThis[Infinity];`)).toBe(true); + expect(usesOutboundNetwork(`function f() {\n const Infinity = 'fetch';\n}\nconst key = Infinity;\nvoid globalThis[key];`)).toBe(true); }); }); From 71dab118907eda57edaea0fd4c1fe81d9c7b6935 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 20:48:46 +0200 Subject: [PATCH 17/35] test(cockpit): close socket delivery surfaces Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LD5MDmE6rJxF9jQawHReGU --- tests/cockpit-host/purity.test.ts | 172 ++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 54 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 64c2c2e..118c537 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1136,16 +1136,31 @@ const isDynamicNodeHttpImport = (node: ts.Node): boolean => { // capability (`req.socket.connect(...)`) the createServer allowance is not meant to grant. // This is the FINAL BOUNDED D3 source policy (commander decision, frozen): the strongest // finite policy that stays compatible with the actual host. It is NOT a taint/alias/type/ -// whole-program engine and does NOT claim literal no-egress — three purely syntactic rules: +// whole-program engine and does NOT claim literal no-egress. It is ONE global static-name +// rule (RULE A over a socket-acquisition name FAMILY) plus its req/res computed refinement +// (RULE A2), both receiver- and position-independent: // -// RULE A — GLOBAL static socket/connection NAME ban, regardless of receiver identity. A -// property named `socket`/`connection` acquired by a statically identifiable name is -// rejected: dotted `x.socket` (optional chaining included), static-computed -// `x['socket']`/`` x[`connection`] ``, and object-destructuring `{ socket }` / -// `{ socket: s }` / `{ connection }` in any binding pattern (variable, parameter, -// nested, callback). Receiver identity is deliberately irrelevant — an unrelated -// `camera.socket` is an accepted, intentional policy false positive; real host/cockpit -// source uses neither name. +// RULE A — GLOBAL static socket-ACQUISITION NAME ban, regardless of receiver identity and +// regardless of call/read position. Two name families are rejected wherever a statically +// identifiable property/binding KEY names them: +// (i) the SOCKET-VALUE names `socket`/`connection` — the duplex socket itself; and +// (ii) the SOCKET-DELIVERY member names `on`/`once`/`addListener`/`prependListener`/ +// `prependOnceListener`/`setTimeout` — the permitted http.Server callbacks that +// hand a socket to a handler (`connection`/`request`/`upgrade`/`connect`/ +// `clientError`/`dropRequest`/`timeout`, plus `setTimeout`'s one-shot timeout +// socket), covered with NO event-name list. +// The ban fires on every statically identifiable form: dotted `x.on` (optional chaining +// included), static-computed `x['on']`/`` x[`socket`] ``, and object-destructuring +// `{ socket }` / `{ on: h }` in any binding pattern (variable, parameter, nested, +// callback). Because it anchors on the member NAME at ANY position — not on a call +// callee — it closes the indirect-registrar family uniformly: `server.on(...)`, +// `server.on.call/apply/bind(...)`, `Reflect.apply(server.on, …)`, `const m = server.on` +// (F2), and `server.setTimeout(t, socket => …)` (F1) all contain the banned name as a +// property access and are rejected at acquisition, with no witness-specific +// `.call`/`.apply`/`.bind` blacklist. Receiver identity is deliberately irrelevant — an +// unrelated `camera.socket`, `emitter.on('ready', …)`, or `obj.setTimeout(…)` is an +// accepted, intentional policy false positive; real host/cockpit source uses none of +// these names (it calls only `http.createServer` and `server.listen`). // // RULE A2 — for the request/response PARAMETERS of a function literal passed DIRECTLY to a // permitted static `createServer` call (binder identity — these are the sole direct @@ -1153,29 +1168,38 @@ const isDynamicNodeHttpImport = (node: ts.Node): boolean => { // is not a static string FAILS CLOSED (`req[key]`, `req['sock'+'et']`, `req[c?…:…]`). // This closes computed recovery on the direct handler param without touching legitimate // host indexing elsewhere (`array[index]`, `text[character]`, `object[key]` are NOT on a -// createServer handler param, so they are unaffected). No const-folding is used. +// createServer handler param, so they are unaffected). No const-folding is used. A2's +// socket/connection semantics are preserved byte-for-byte by the RULE A name-family +// promotion above (A2 keeps consulting `SOCKET_CAPABILITY_NAMES`, not the wider family). // -// RULE B — BLANKET event-registration ban. Any call whose callee member is a registrar -// name (`on`/`once`/`addListener`/`prependListener`/`prependOnceListener`, read from a -// dotted or static-key access) is rejected regardless of receiver, event name, or handler -// shape. Real D3 host/cockpit source registers NO events, so this eliminates every -// socket-delivering event route — `request`, `connection`, `upgrade`, `connect`, -// `clientError`, `dropRequest`, and any future one — with no event-name list to maintain -// and no wrapper/receiver inference. The accepted, bounded false positive is that an -// unrelated synthetic `emitter.on('ready', …)` is also rejected. -// -// HONEST BOUNDARY (frozen, not a defect): an aliased/cross-function request combined with a -// runtime-computed key — `const r = request; r[runtimeKey]` where `runtimeKey` becomes -// `'socket'` at runtime — is NOT closed; closing it would require alias propagation / type -// resolution / whole-program flow, deliberately excluded here. It belongs to a future -// runtime-isolation enforcement boundary, not this source policy. +// HONEST BOUNDARY (frozen, not a defect): a socket-delivering member acquired WITHOUT its +// name ever appearing statically — a cross-function alias combined with a runtime-computed +// key, `const r = request; r[runtimeKey]` where `runtimeKey` becomes `'socket'` at runtime +// (or a registrar/`setTimeout` reached as `server[k]` with runtime `k`) — is NOT closed; +// closing it would require alias propagation / type resolution / whole-program flow, +// deliberately excluded here. It belongs to a future runtime-isolation enforcement boundary, +// not this source policy. +// The SOCKET-VALUE names (RULE A family i) — the duplex socket itself. Still used verbatim by +// the RULE A2 req/res-bound branches below, whose socket/connection semantics are preserved. const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connection']); -const SOCKET_EVENT_REGISTRARS: ReadonlySet = new Set([ +// The SOCKET-DELIVERY member names (RULE A family ii) — the permitted http.Server callbacks +// that hand a socket to a handler. `setTimeout` (the one-shot 'timeout' socket — F1) sits +// beside the five event registrars; the whole family is banned by NAME at any position (F2), +// never by call shape, so `.call`/`.apply`/`.bind`/`Reflect.apply`/`const m = server.on` +// cannot launder it, and there is NO event-name list to maintain. +const SOCKET_DELIVERY_MEMBERS: ReadonlySet = new Set([ 'on', 'once', 'addListener', 'prependListener', 'prependOnceListener', + 'setTimeout', +]); +// The full RULE A static name family (i ∪ ii), tested at every statically identifiable +// property/binding-key position (dotted, static-computed, destructured), receiver-independent. +const STATIC_SOCKET_ACQUISITION_NAMES: ReadonlySet = new Set([ + ...SOCKET_CAPABILITY_NAMES, + ...SOCKET_DELIVERY_MEMBERS, ]); // The static key named by an element-access argument or a binding-element key, or null. @@ -1222,23 +1246,34 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou let found = false; const visit = (node: ts.Node): void => { - // RULE A (a) — GLOBAL dotted `.socket`/`.connection` (optional chaining included). - if (ts.isPropertyAccessExpression(node) && SOCKET_CAPABILITY_NAMES.has(node.name.text)) found = true; - // RULE A (b) — GLOBAL static-computed `['socket']`/`['connection']`; else RULE A2 fails - // closed on an indeterminate computed key when the receiver is a createServer param. + // RULE A (a) — GLOBAL dotted socket-acquisition NAME: `.socket`/`.connection` or a delivery + // member `.on`/`.once`/`.addListener`/`.prependListener`/`.prependOnceListener`/ + // `.setTimeout` (optional chaining included), any receiver, any position. This is the node + // that closes the indirect-registrar family (F2): the inner `server.on` inside + // `server.on.call(...)` / `server.on.apply(...)` / `server.on.bind(...)` / + // `Reflect.apply(server.on, …)` / `const m = server.on`, and the `server.setTimeout` + // receiver of `server.setTimeout(t, socket => …)` (F1), are each a property access + // visited here regardless of how (or whether) they are later invoked. + if (ts.isPropertyAccessExpression(node) && STATIC_SOCKET_ACQUISITION_NAMES.has(node.name.text)) found = true; + // RULE A (b) — GLOBAL static-computed socket-acquisition NAME `['socket']`/`['connection']`/ + // `['on']`/…/`['setTimeout']` (any receiver, e.g. `server['on']('connection', …)`); else + // RULE A2 fails closed on an indeterminate computed key when the receiver is a + // createServer param. The A2 (receiverIsReqRes) branch is unchanged. if (ts.isElementAccessExpression(node)) { const arg = node.argumentExpression; if (ts.isStringLiteralLike(arg)) { - if (SOCKET_CAPABILITY_NAMES.has(arg.text)) found = true; + if (STATIC_SOCKET_ACQUISITION_NAMES.has(arg.text)) found = true; } else if (receiverIsReqRes(node.expression)) { found = true; } } - // RULE A (c) — GLOBAL `{ socket }` / `{ connection }` destructuring in any object binding - // pattern (variable, parameter, nested, callback), including `{ socket: s }`. + // RULE A (c) — GLOBAL socket-acquisition NAME destructuring in any object binding pattern + // (variable, parameter, nested, callback): `{ socket }` / `{ connection }` / + // `{ on }` / `{ setTimeout }`, including the renamed `{ on: h }` / `{ socket: s }` form + // (the static source KEY is what is banned, never the local binding name). if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { const name = staticKeyText(node.propertyName ?? node.name); - if (name !== null && SOCKET_CAPABILITY_NAMES.has(name)) found = true; + if (name !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(name)) found = true; } // RULE A2 (destructuring) — an INDETERMINATE computed binding key destructured DIRECTLY // from a createServer request/response handler parameter fails closed (bound to the @@ -1291,15 +1326,14 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } } } - // RULE B — BLANKET event-registration ban: any registrar member call, any receiver, any - // event name (dotted `.on` or static-key `['on']`). - if ( - ts.isCallExpression(node) && - (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression)) - ) { - const registrar = binderMemberName(node.expression); - if (registrar !== null && SOCKET_EVENT_REGISTRARS.has(registrar)) found = true; - } + // RULE B (PROMOTED into RULE A's name family) — the event registrars and `setTimeout` are + // now banned by NAME at RULE A (a)/(b)/(c) above, receiver- and position-independent, so + // the former call-callee-only registrar ban is fully subsumed (a called `server.on(...)` + // is caught by its `.on` property access, exactly like an uncalled `const m = server.on`). + // Anchoring on the member NAME rather than the call callee is precisely what closes the + // `.call`/`.apply`/`.bind`/`Reflect.apply`/method-extraction indirection (F2) and the + // `setTimeout` delivery surface (F1) — with NO witness-specific `.call`/`.apply`/`.bind` + // blacklist and NO event-name enumeration. No separate call-shaped rule remains. ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); @@ -4879,17 +4913,21 @@ describe('D3 host prohibits runtime dynamic import of node:http (D3-CX-POLICY-NE }); // --------------------------------------------------------------------------- -// SOCK final bounded D3 socket policy (D3-CX-POLICY-NET-SOCK). Three finite, purely -// syntactic rules, no taint/alias/type/whole-program: RULE A — a GLOBAL static ban on -// acquiring a property named `socket`/`connection` (dotted, optional, static-computed, or -// destructured) regardless of receiver; RULE A2 — on the request/response parameters of a -// function literal passed directly to a permitted `createServer`, an indeterminate computed -// element access fails closed; RULE B — a BLANKET ban on every event registrar call -// (`on`/`once`/`addListener`/`prependListener`/`prependOnceListener`), any receiver, any -// event name. Accepted, intentional false positives: an unrelated `camera.socket` and an -// unrelated `emitter.on('ready', …)` are rejected because real host/cockpit source uses -// neither. The alias + runtime-computed residual (`const r = request; r[runtimeKey]`) is the -// frozen honest boundary, deliberately not closed here. +// SOCK final bounded D3 socket policy (D3-CX-POLICY-NET-SOCK). Finite, purely syntactic, no +// taint/alias/type/whole-program: RULE A — a GLOBAL static ban on acquiring a socket-ACQUISITION +// NAME by any statically identifiable property/binding key, receiver- AND position-independent. +// The name family is (i) the socket-value names `socket`/`connection` and (ii) the socket-delivery +// member names `on`/`once`/`addListener`/`prependListener`/`prependOnceListener`/`setTimeout` — +// the permitted http.Server callbacks that hand over a socket, with no event-name list. Anchoring +// on the NAME (not the call callee) folds in the former registrar ban and closes the indirect +// family: `server.on(...)`, `server.on.call/apply/bind(...)`, `Reflect.apply(server.on, …)`, +// `const m = server.on` (F2), and `server.setTimeout(…, socket => …)` (F1). RULE A2 — on the +// request/response parameters of a function literal passed directly to a permitted `createServer`, +// an indeterminate computed element access fails closed (socket/connection semantics unchanged). +// Accepted, intentional false positives: an unrelated `camera.socket`, `emitter.on('ready', …)`, +// or `obj.setTimeout(…)` is rejected because real host/cockpit source uses none of these names. +// The alias + runtime-computed residual (`const r = request; r[runtimeKey]`, or `server[k]` with +// runtime `k`) is the frozen honest boundary, deliberately not closed here. // --------------------------------------------------------------------------- describe('D3 host enforces the final bounded socket-capability source policy (D3-CX-POLICY-NET-SOCK)', () => { it('accepts every real host source (no host source acquires the inbound socket)', () => { @@ -4953,7 +4991,7 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'a conditional req[c ? "socket" : "method"] on the handler param', source: handler(`const c = req.method === 'GET';\nvoid req[c ? 'socket' : 'method'];`) }, ]; - // --- RULE B: BLANKET event-registration ban (any registrar, any receiver, any event) --- + // --- RULE A (promoted): registrar-name ban, now by NAME at any position (direct call form) --- const registrarNames = ['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']; const anyEventNames = ['request', 'connection', 'dropRequest', 'ready']; const wrapperHead = @@ -4975,12 +5013,34 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: "a synthetic LocalEmitter registering 'anything' (accepted false positive)", source: `class LocalEmitter {\n addListener(_event: string, _cb: () => void): void {}\n}\nconst emitter = new LocalEmitter();\nemitter.addListener('anything', () => {});` }, ]; + // --- ADVERSARIAL MATRIX (frozen DDR): F1 setTimeout surface + F2 indirect-registrar family. + // Each is closed by the RULE A member-NAME ban (the banned name is a property/element + // access somewhere in the source), with NO witness-specific `.call`/`.apply`/`.bind` list. --- + const familyReject: readonly { readonly form: string; readonly source: string }[] = [ + // F1 — server.setTimeout delivers the connection socket to its callback. + { form: 'F1: server.setTimeout(t, socket => socket.connect(...))', source: wrapperHead + `server.setTimeout(2000, (socket: { connect(p: number, h: string): void }) => {\n socket.connect(80, 'example.com');\n});` }, + { form: 'F1: server.setTimeout acquired via .bind', source: wrapperHead + `const t = server.setTimeout.bind(server);\nvoid t;` }, + // F2 — the same registrar reached indirectly; the inner `server.on` name is what is banned. + { form: 'F2: server.on.call(server, "connection", …)', source: wrapperHead + `server.on.call(server, 'connection', (socket: unknown) => {\n void socket;\n});` }, + { form: 'F2: server.on.apply(server, [...])', source: wrapperHead + `server.on.apply(server, ['connection', (socket: unknown) => {\n void socket;\n}]);` }, + { form: 'F2: server.on.bind(server)(...)', source: wrapperHead + `const reg = server.on.bind(server);\nreg('connection', (socket: unknown) => {\n void socket;\n});` }, + { form: 'F2: Reflect.apply(server.on, server, [...])', source: wrapperHead + `Reflect.apply(server.on, server, ['connection', (socket: unknown) => {\n void socket;\n}]);` }, + { form: 'F2: method-extraction const m = server.on; m.call(...)', source: wrapperHead + `const m = server.on;\nm.call(server, 'connection', (socket: unknown) => {\n void socket;\n});` }, + { form: 'F2: static-key extraction server["on"].call(...)', source: wrapperHead + `server['on'].call(server, 'connection', () => {});` }, + { form: 'F2: const m = server.on rejected at acquisition (no call)', source: wrapperHead + `const m = server.on;\nvoid m;` }, + { form: 'F2: server.once.call(server, "upgrade", …)', source: wrapperHead + `server.once.call(server, 'upgrade', () => {});` }, + // Intentionally broad static policy — unrelated .on / .setTimeout are rejected too. + { form: 'benign unrelated object.on (accepted broad-policy false positive)', source: `const obj = { on(_e: string, _c: () => void): void {} };\nobj.on('x', () => {});` }, + { form: 'benign unrelated object.setTimeout (accepted broad-policy false positive)', source: `const timer = { setTimeout(_m: number, _c: () => void): void {} };\ntimer.setTimeout(0, () => {});` }, + ]; + for (const { form, source } of [ ...ruleAReject, ...destructureReject, ...ruleA2Reject, ...eventReject, ...eventRejectSpecial, + ...familyReject, ]) { it(`rejects ${form}`, () => { expect(usesOutboundNetwork(source)).toBe(true); @@ -5000,6 +5060,10 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'unrelated text[character] indexing', source: `const text = 'abc';\nconst character = 1;\nvoid text[character];` }, { form: 'unrelated object[key] indexing', source: `const object: Record = {};\nconst key = 'a';\nvoid object[key];` }, { form: 'a non-socket ordinary member on an unrelated object', source: `const obj = { value: 1 };\nvoid obj.value;` }, + // Matrix item 17 — the frozen honest boundary stays OUTSIDE the proof; the mechanism must + // NOT be broadened to close it (doing so would need alias/type/whole-program analysis). + { form: 'the frozen alias + runtime-key residual on req (remains allowed)', source: handler(`const r = req;\nconst k = req.url ?? '';\nvoid r[k];`) }, + { form: 'the frozen runtime-computed server[k] residual (remains allowed)', source: wrapperHead + `const k = String(4317);\nvoid server[k];` }, ]; for (const { form, source } of sockAllow) { it(`allows ${form}`, () => { From 55f3adbf281cdb7e57d5fe53b5a54a2cb491416a Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 21:45:54 +0200 Subject: [PATCH 18/35] test(cockpit): propagate nested HTTP capabilities --- tests/cockpit-host/purity.test.ts | 111 ++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 118c537..407ef13 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -935,8 +935,61 @@ const isDirectNodeHttpNamespace = (symbol: ts.Symbol | undefined): boolean => { ); }; +// The static property KEY an object-binding element reads from its receiver: an explicit +// `propertyName` (identifier / string-literal / static computed string-literal), or, for the +// shorthand `{ name }`, the bound identifier itself. Null when the key is not statically +// identifiable — fail-closed as HTTP_CLIENT off an HTTP_NS receiver, exactly as the original +// single-hop rule did. +const bindingElementKey = (el: ts.BindingElement): string | null => { + const key = el.propertyName; + if (key === undefined) return ts.isIdentifier(el.name) ? el.name.text : null; + if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) return key.text; + if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text; + return null; +}; + +// The node:http capability a member named `key` yields off a receiver of capability `recv`, +// mirroring member-access semantics (`classifyHttpExpression`): off the namespace, +// `createServer` is the one permitted server value and every other member (a non-static key +// included) is an outbound HTTP_CLIENT; off an HTTP_CLIENT value every member stays +// HTTP_CLIENT; nothing else propagates. No new capability kind, no member blacklist. +const httpMemberCapability = (recv: HttpCapability, key: string | null): HttpCapability => { + if (recv === 'HTTP_NS') return key !== null && HTTP_SERVER_VALUE_MEMBERS.has(key) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; + if (recv === 'HTTP_CLIENT') return 'HTTP_CLIENT'; + return 'NONE'; +}; + +// The capability a single OBJECT-destructuring binding element acquires, propagated +// RECURSIVELY through nested object binding patterns from the enclosing variable +// declaration's initializer (still the sole acquisition root, resolved ONE hop to the +// node:http namespace — no alias chains, no value-flow). A top-level element +// `{ request } = http` reduces to the original single-hop rule; a nested +// `{ globalAgent: { createConnection } } = http` propagates HTTP_NS → HTTP_CLIENT → +// HTTP_CLIENT structurally down the pattern until this element's bound name is reached. +// Only object patterns carry named members; an array binding pattern or a non-namespace +// initializer does not propagate. Finite: one step per binding-pattern nesting level. +const bindingElementHttpCapability = (el: ts.BindingElement, checker: ts.TypeChecker): HttpCapability => { + const pattern = el.parent; + if (!ts.isObjectBindingPattern(pattern)) return 'NONE'; + const container = pattern.parent; + let receiver: HttpCapability; + if (ts.isVariableDeclaration(container)) { + const initializer = container.initializer; + if (initializer === undefined) return 'NONE'; + const rhs = binderUnwrap(initializer); + receiver = ts.isIdentifier(rhs) && isDirectNodeHttpNamespace(checker.getSymbolAtLocation(rhs)) ? 'HTTP_NS' : 'NONE'; + } else if (ts.isBindingElement(container)) { + receiver = bindingElementHttpCapability(container, checker); + } else { + return 'NONE'; + } + if (receiver === 'NONE') return 'NONE'; + return httpMemberCapability(receiver, bindingElementKey(el)); +}; + // Classify a single DECLARATION. Bounded: a node:http import, or a destructuring whose -// initializer DIRECTLY resolves to the node:http namespace (one hop, no alias chains). +// initializer DIRECTLY resolves to the node:http namespace (one hop, no alias chains), +// propagated through nested object binding patterns to the bound name. const classifyHttpDeclaration = (decl: ts.Declaration, checker: ts.TypeChecker): HttpCapability => { if (ts.isNamespaceImport(decl)) return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; if (ts.isImportClause(decl) && decl.name !== undefined) { @@ -948,27 +1001,8 @@ const classifyHttpDeclaration = (decl: ts.Declaration, checker: ts.TypeChecker): const imported = (decl.propertyName ?? decl.name).text; return HTTP_SERVER_VALUE_MEMBERS.has(imported) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; } - if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent) && ts.isVariableDeclaration(decl.parent.parent)) { - const initializer = decl.parent.parent.initializer; - if (initializer !== undefined) { - const rhs = binderUnwrap(initializer); - if (ts.isIdentifier(rhs) && isDirectNodeHttpNamespace(checker.getSymbolAtLocation(rhs))) { - const key = decl.propertyName; - const member = - key === undefined - ? ts.isIdentifier(decl.name) - ? decl.name.text - : null - : ts.isIdentifier(key) - ? key.text - : ts.isStringLiteralLike(key) - ? key.text - : ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression) - ? key.expression.text - : null; - return member !== null && HTTP_SERVER_VALUE_MEMBERS.has(member) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; - } - } + if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent)) { + return bindingElementHttpCapability(decl, checker); } return 'NONE'; }; @@ -4680,6 +4714,39 @@ describe('D3 host tracks node:http capability through dynamic import and destruc ), ).toBe(true); }); + + // F2 (NESTED) — capability propagates RECURSIVELY through nested object destructuring off an + // HTTP_NS binding (Codex P1 witness). The inner binding element sits under an OUTER binding + // element rather than directly under the variable declaration, yet still acquires the + // outbound client member. Each level reduces to the single-hop F2 member rule (HTTP_NS → + // createServer is the one server value, every other member is HTTP_CLIENT; off an + // HTTP_CLIENT value every member stays HTTP_CLIENT). No alias chains, no value-flow: the + // acquisition root is still the one-hop node:http namespace initializer, walked structurally + // down the binding pattern. Array patterns and non-namespace initializers do not propagate. + const nestedReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the exact Codex witness: nested { globalAgent: { createConnection } } off a namespace import', source: `import * as http from 'node:http';\nconst {\n globalAgent: { createConnection },\n} = http;\ncreateConnection({ host: 'example.com', port: 80 });` }, + { form: 'a nested aliased { globalAgent: { createConnection: connect } }', source: `import * as http from 'node:http';\nconst {\n globalAgent: { createConnection: connect },\n} = http;\nconnect({ host: 'example.com', port: 80 });` }, + { form: 'a nested default { globalAgent: { createConnection: connect = fallback } }', source: `import * as http from 'node:http';\nconst fallback = (_o: { host: string; port: number }): void => {};\nconst {\n globalAgent: { createConnection: connect = fallback },\n} = http;\nconnect({ host: 'example.com', port: 80 });` }, + { form: 'a three-level nested { globalAgent: { pool: { createConnection } } }', source: `import * as http from 'node:http';\nconst {\n globalAgent: { pool: { createConnection } },\n} = http;\ncreateConnection({ host: 'example.com', port: 80 });` }, + { form: 'the same nesting off a default import resolving to the same HTTP_NS binding', source: `import http from 'node:http';\nconst {\n globalAgent: { createConnection },\n} = http;\ncreateConnection({ host: 'example.com', port: 80 });` }, + ]; + for (const { form, source } of nestedReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const nestedAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a top-level { createServer } off a namespace import (unchanged)', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, + { form: 'a nested { createServer } that stays the one permitted server value', source: `import * as http from 'node:http';\nconst {\n createServer,\n} = http;\ncreateServer(() => {});` }, + { form: 'the same nested shape off an unrelated local object (not node:http)', source: `const cfg = {\n globalAgent: { createConnection: (_o: { host: string }) => _o },\n};\nconst {\n globalAgent: { createConnection },\n} = cfg;\nvoid createConnection({ host: 'x' });` }, + { form: 'a type-only namespace import used only in a type position (unchanged)', source: `import type * as http from 'node:http';\ntype Conn = http.Server;\nvoid 0 as unknown as Conn;` }, + ]; + for (const { form, source } of nestedAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } }); // --------------------------------------------------------------------------- From 5227ba32f31a5bc95beb9054149211c26ed52802 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 30 Aug 2026 22:50:38 +0200 Subject: [PATCH 19/35] test(cockpit): canonicalize bounded network capability access --- tests/cockpit-host/purity.test.ts | 173 ++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 11 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 407ef13..0b8d7c0 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -337,9 +337,32 @@ const unwrapExpr = (node: ts.Expression): ts.Expression => { return cur; }; +// A global-object SELF-REFERENCE hop: the member NAME read off a receiver when that +// receiver is a member access whose key is a statically present string +// (`globalThis.globalThis`, `window['window']`). The real global exposes itself under +// every GLOBAL_RECEIVER_NAMES key (`globalThis.globalThis === globalThis`, +// `globalThis.window === globalThis`, …), so a chain of such hops off a global base still +// denotes the real global. Returns the hop name or null; it NEVER folds a runtime-built +// key (no alias/value-flow), so a truly computed key stays outside the frozen boundary. +const selfReferenceHopName = ( + node: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null; +}; + +// A structural global receiver: a bare global-object identifier, OR a self-reference member +// (name in GLOBAL_RECEIVER_NAMES) read off another structural global receiver — so +// `globalThis.globalThis`, `globalThis.window`, `window.window` are all recognized. Finite: +// each recursion strips one member-access layer off `node.expression`. const isGlobalReceiver = (node: ts.Expression): boolean => { const n = unwrapExpr(node); - return ts.isIdentifier(n) && GLOBAL_RECEIVER_NAMES.has(n.text); + if (ts.isIdentifier(n)) return GLOBAL_RECEIVER_NAMES.has(n.text); + if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { + const hop = selfReferenceHopName(n); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression); + } + return false; }; // The statically-provable string value of an expression: a string literal or @@ -363,6 +386,16 @@ const staticStringOf = (node: ts.Expression, constMap: ReadonlyMap => if (left === null) return null; const right = resolveExpr(n.right); if (right === null) return null; + // TOTALITY: never build a fold longer than any name this resolver is compared against. + if (left.length + right.length > MAX_STATIC_FOLD_LEN) return null; return left + right; } if (ts.isIdentifier(n)) return resolveName(n.text); @@ -1151,10 +1186,22 @@ const hasLocalRuntimeShadow = (symbol: ts.Symbol, sourceFile: ts.SourceFile): bo // free. Restoration/nesting/sibling scope are the binder's job as before. const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { const recv = binderUnwrap(expr); - if (!ts.isIdentifier(recv) || !GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; - const symbol = checker.getSymbolAtLocation(recv); - if (symbol === undefined) return true; - return !hasLocalRuntimeShadow(symbol, sourceFile); + if (ts.isIdentifier(recv)) { + if (!GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; + const symbol = checker.getSymbolAtLocation(recv); + if (symbol === undefined) return true; + return !hasLocalRuntimeShadow(symbol, sourceFile); + } + // Global-object self-reference hop (`globalThis.globalThis`, `window.window`, …): the member + // name is a global-receiver name and its own receiver is (recursively) a free global. Binder/ + // shadowing authority stays at the BASE identifier above — a shadowed base + // (`function f(globalThis){ globalThis.globalThis.fetch() }`) demotes the whole chain to an + // ordinary object. Finite: recursion descends `recv.expression`. + if (ts.isPropertyAccessExpression(recv) || ts.isElementAccessExpression(recv)) { + const hop = selfReferenceHopName(recv); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isFreeGlobalReceiver(recv.expression, checker, sourceFile); + } + return false; }; // Whether a call is a runtime dynamic `import('node:http')` (Option B: prohibited outright). @@ -1249,6 +1296,13 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou const isCreateServerCall = (node: ts.Node): boolean => ts.isCallExpression(node) && classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; + // DDR-B: the SAME bounded static-string resolver the RC/HA member-name check uses, so a + // statically constructed socket-acquisition key (`server['o' + 'n']`, + // `const k = 'on'; server[k]`) resolves to its name. Receiver-independent, fail-closed: a + // genuinely runtime key resolves to null and stays outside the proof (frozen boundary). NET's + // own binder-based key resolver is untouched; this map is local to SOCK. + const constMap = collectStringConsts(sourceFile); + // Pass 1 (RULE A2 support) — collect the createServer request/response parameter symbols. // Direct identifier params only; a destructured param `({ socket })` is a RULE A binding // pattern, rejected in pass 2 like any other. @@ -1295,18 +1349,28 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // createServer param. The A2 (receiverIsReqRes) branch is unchanged. if (ts.isElementAccessExpression(node)) { const arg = node.argumentExpression; - if (ts.isStringLiteralLike(arg)) { - if (STATIC_SOCKET_ACQUISITION_NAMES.has(arg.text)) found = true; - } else if (receiverIsReqRes(node.expression)) { - found = true; - } + const resolved = staticStringOf(arg, constMap); + // RULE A extension (DDR-B): a statically-resolvable socket-acquisition NAME, ANY receiver + // (`server['o' + 'n']`, `const k='on'; server[k]`, `` server[`socket`] ``). + if (resolved !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(resolved)) found = true; + // RULE A2 (unchanged): a non-string-literal computed key on a createServer req/res param + // fails closed. The trigger stays keyed on `!isStringLiteralLike`, so A2's fail-closed + // surface is byte-for-byte what it was — the resolver only ADDS global name rejections. + else if (!ts.isStringLiteralLike(arg) && receiverIsReqRes(node.expression)) found = true; } // RULE A (c) — GLOBAL socket-acquisition NAME destructuring in any object binding pattern // (variable, parameter, nested, callback): `{ socket }` / `{ connection }` / // `{ on }` / `{ setTimeout }`, including the renamed `{ on: h }` / `{ socket: s }` form // (the static source KEY is what is banned, never the local binding name). if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { - const name = staticKeyText(node.propertyName ?? node.name); + const keyNode = node.propertyName ?? node.name; + // RULE A extension (DDR-B): a statically-resolvable COMPUTED destructuring key + // (`{ ['o'+'n']: h }`, `{ [k]: h }` with `const k='on'`) resolves via the SAME + // resolver; a plain key keeps its existing staticKeyText path. The separate A2 + // destructuring branch below is untouched, so its fail-closed surface is preserved. + const name = ts.isComputedPropertyName(keyNode) + ? staticStringOf(keyNode.expression, constMap) + : staticKeyText(keyNode); if (name !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(name)) found = true; } // RULE A2 (destructuring) — an INDETERMINATE computed binding key destructured DIRECTLY @@ -5137,6 +5201,93 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(false); }); } + + // --- DDR-B: statically CONSTRUCTED socket-acquisition KEYS resolve via the shared + // static-string resolver, any receiver — MUST REJECT. `server['o' + 'n']` is the + // reported reproduction. A genuinely runtime key resolves to null and stays outside. --- + const ddrBReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: "the reported server['o' + 'n']('connection', …) reproduction", source: wrapperHead + `server['o' + 'n']('connection', (s: { destroy(): void; connect(p: number, h: string): void }) => {\n s.destroy();\n setTimeout(() => s.connect(80, 'example.com'), 50);\n});` }, + { form: "a literal server['on']", source: wrapperHead + `server['on']('connection', () => {});` }, + { form: "a const-bound key const k = 'on'; server[k]", source: wrapperHead + `const k = 'on';\nserver[k]('connection', () => {});` }, + { form: 'a template server[`on`]', source: wrapperHead + 'server[`on`](\'connection\', () => {});' }, + { form: "a concatenated socket value server['sock' + 'et']", source: wrapperHead + `void server['sock' + 'et'];` }, + { form: "a concatenated server['connec' + 'tion']", source: wrapperHead + `void server['connec' + 'tion'];` }, + { form: "a concatenated setTimeout server['set' + 'Timeout']", source: wrapperHead + `void server['set' + 'Timeout'];` }, + { form: "the destructuring twin const { ['o'+'n']: h } = server", source: wrapperHead + `const { ['o' + 'n']: h } = server;\nvoid h;` }, + { form: "a const-bound destructuring key const k='socket'; { [k]: s } = server", source: wrapperHead + `const k = 'socket';\nconst { [k]: s } = server;\nvoid s;` }, + ]; + for (const { form, source } of ddrBReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- DDR-B: the frozen runtime-computed boundary stays OUTSIDE the proof — MUST ALLOW. + // A key that is not statically resolvable (a call result, an ambient runtime name) is + // unchanged; closing it would need alias/type/whole-program flow, deliberately excluded. --- + const ddrBAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a genuinely runtime server[runtimeKey] (declared, unresolvable)', source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, + { form: 'a call-result key server[String(4317)] (unresolvable)', source: wrapperHead + `void server[String(4317)];` }, + { form: 'a harmless resolvable non-socket key server["lis" + "ten"]', source: wrapperHead + `void server['lis' + 'ten'];` }, + ]; + for (const { form, source } of ddrBAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + +// --------------------------------------------------------------------------- +// DDR-A — a global-object SELF-REFERENCE chain (`globalThis.globalThis`, `window.window`, +// `globalThis.global`, …) denotes the real global, so a network member read off it is egress. +// Recognition is structural and finite (each hop is a member name in GLOBAL_RECEIVER_NAMES off +// a recursively-global receiver); binder/shadowing authority stays at the BASE identifier, so a +// shadowed base is an ordinary object and the `const g = globalThis.globalThis; g.fetch()` alias +// residual stays OUTSIDE the frozen boundary (no alias/value-flow). +// --------------------------------------------------------------------------- +describe('D3 host recognizes global-object self-reference chains as global receivers (DDR-A)', () => { + const selfRefReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'globalThis.fetch (base case)', source: `globalThis.fetch('https://evil.example/');` }, + { form: 'globalThis.globalThis.fetch', source: `globalThis.globalThis.fetch('https://evil.example/');` }, + { form: 'globalThis.global.fetch', source: `globalThis.global.fetch('https://evil.example/');` }, + { form: 'globalThis.window.fetch (fail-closed)', source: `globalThis.window.fetch('https://evil.example/');` }, + { form: 'window.window.fetch', source: `window.window.fetch('https://evil.example/');` }, + { form: 'self.self.fetch', source: `self.self.fetch('https://evil.example/');` }, + { form: 'multi-hop globalThis.globalThis.globalThis.fetch', source: `globalThis.globalThis.globalThis.fetch('https://evil.example/');` }, + { form: "string-literal hop globalThis['globalThis'].fetch", source: `globalThis['globalThis'].fetch('https://evil.example/');` }, + { form: 'a self-reference chain to the second global new globalThis.globalThis.WebSocket(...)', source: `void new globalThis.globalThis.WebSocket('wss://evil.example/');` }, + { form: 'destructuring fetch off a self-reference chain', source: `const { fetch } = globalThis.globalThis;\nvoid fetch('https://evil.example/');` }, + ]; + for (const { form, source } of selfRefReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + const selfRefAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a shadowed base parameter globalThis (ordinary local object)', source: `function f(globalThis: { globalThis: { fetch(x: string): void } }): void {\n globalThis.globalThis.fetch('https://evil.example/');\n}\nvoid f;` }, + { form: 'an unrelated obj.globalThis.fetch (base not a global receiver)', source: `const obj = { globalThis: { fetch: (x: string) => x } };\nvoid obj.globalThis.fetch('x');` }, + { form: 'the frozen alias residual const g = globalThis.globalThis; g.fetch() (remains allowed)', source: `const g = globalThis.globalThis;\nvoid g.fetch('https://evil.example/');` }, + { form: 'a non-network member off a self-reference chain globalThis.globalThis.crypto', source: `void globalThis.globalThis.crypto;` }, + ]; + for (const { form, source } of selfRefAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // RC twin — the SAME self-reference recursion in the structural `isGlobalReceiver` closes + // `globalThis.globalThis.eval(...)` in the runtime-code-generation policy. + const rcTwinReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'globalThis.globalThis.eval(...)', source: `globalThis.globalThis.eval("import('../domain/actions.js')");` }, + { form: 'window.window.Function(...)', source: `window.window.Function('return import("../domain/actions.js")')();` }, + { form: "string-literal hop globalThis['globalThis'].eval(...)", source: `globalThis['globalThis']['eval']("import('../domain/actions.js')");` }, + ]; + for (const { form, source } of rcTwinReject) { + it(`rejects (RC twin) ${form}`, () => { + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } }); // --------------------------------------------------------------------------- From 564984028588079dd9f0613b71c52c12849e3ddc Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 00:22:38 +0200 Subject: [PATCH 20/35] test(cockpit): unify NET static key handling Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E1LCJ4qDBryGGNmw4YUkAQ --- tests/cockpit-host/purity.test.ts | 192 ++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 12 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 0b8d7c0..cd63860 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1196,9 +1196,13 @@ const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sour // name is a global-receiver name and its own receiver is (recursively) a free global. Binder/ // shadowing authority stays at the BASE identifier above — a shadowed base // (`function f(globalThis){ globalThis.globalThis.fetch() }`) demotes the whole chain to an - // ordinary object. Finite: recursion descends `recv.expression`. + // ordinary object. DDR-NET-STATIC-KEY-PARITY (F1): the ELEMENT-access hop key is folded by the + // bounded binder resolver (`netHopName`), so `globalThis['global' + 'This']`, + // `const k = 'globalThis'; globalThis[k]`, and `` globalThis[`glob` + 'alThis'] `` are recognized + // hops, while a genuinely runtime key resolves to null and is NOT a hop. Finite: recursion + // descends `recv.expression`. if (ts.isPropertyAccessExpression(recv) || ts.isElementAccessExpression(recv)) { - const hop = selfReferenceHopName(recv); + const hop = ts.isPropertyAccessExpression(recv) ? recv.name.text : netHopName(recv.argumentExpression, checker); return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isFreeGlobalReceiver(recv.expression, checker, sourceFile); } return false; @@ -1563,20 +1567,26 @@ const netResolveKey = ( memo: Map, budget: { spent: number }, depth: number, + // The longest name any consumer of THIS resolution compares against: `MAX_NETWORK_MEMBER_LENGTH` + // for a network member key, `MAX_GLOBAL_RECEIVER_LENGTH` for a self-reference hop key (a hop can + // fold to `globalThis`, 10 > the 9-char network ceiling, so the ceiling must travel with the + // call). `memo` is keyed by declaration AND is caller-scoped to a single `maxLen`, so a + // NotCapability decided under one ceiling can never be read back under the other. + maxLen: number, ): NetKey => { if (depth > NET_RESOLVE_DEPTH_CAP) throw new NetResolveAbort(); // resource bound: not memoized const n = unwrapExpr(node); if (ts.isStringLiteralLike(n)) return { kind: 'resolved', value: n.text }; if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = netResolveKey(n.left, checker, seen, memo, budget, depth + 1); + const left = netResolveKey(n.left, checker, seen, memo, budget, depth + 1, maxLen); if (left.kind === 'indeterminate') return NET_INDETERMINATE; if (left.kind === 'notCapability') return NET_NOT_CAPABILITY; // already too long; `+` only grows it - const right = netResolveKey(n.right, checker, seen, memo, budget, depth + 1); + const right = netResolveKey(n.right, checker, seen, memo, budget, depth + 1, maxLen); if (right.kind === 'indeterminate') return NET_INDETERMINATE; if (right.kind === 'notCapability') return NET_NOT_CAPABILITY; // Bound OUTPUT before allocating `left + right`: an oversized result is provably NotCapability, // so no exponential intermediate is ever materialized. - if (left.value.length + right.value.length > MAX_NETWORK_MEMBER_LENGTH) return NET_NOT_CAPABILITY; + if (left.value.length + right.value.length > maxLen) return NET_NOT_CAPABILITY; return { kind: 'resolved', value: left.value + right.value }; } if (ts.isIdentifier(n)) { @@ -1612,7 +1622,7 @@ const netResolveKey = ( cur = init; // alias hop: iterate, no recursion continue; } - key = netResolveKey(init, checker, seen, memo, budget, depth + 1); // literal / `+`-fold + key = netResolveKey(init, checker, seen, memo, budget, depth + 1, maxLen); // literal / `+`-fold break; } // Reached only on a NON-abort return (a thrown NetResolveAbort unwinds past this, leaving the @@ -1641,13 +1651,58 @@ const netMemberKey = ( ): NetKey => { if (ts.isPropertyAccessExpression(node)) return { kind: 'resolved', value: node.name.text }; try { - return netResolveKey(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0); + return netResolveKey(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); } catch (error) { if (error instanceof NetResolveAbort) return NET_INDETERMINATE; throw error; } }; +// The longest global-receiver name (`globalThis` = 10). A self-reference hop key can fold to it, +// so the hop resolver's `+`-fold ceiling must reach 10 — one above the 9-char network member +// ceiling — or `globalThis['global' + 'This']` would be pruned as NotCapability before resolving. +const MAX_GLOBAL_RECEIVER_LENGTH = Math.max(...[...GLOBAL_RECEIVER_NAMES].map((name) => name.length)); + +// DDR-NET-STATIC-KEY-PARITY (F1) — the self-reference HOP name an element-access key denotes, +// resolved by the SAME bounded binder resolver used for network member keys (`netResolveKey`), +// never by identifier text: a string literal / substitution-free template, a `+`-fold, or a +// binder-proven unique `const` chain, capped at `MAX_GLOBAL_RECEIVER_LENGTH`. Only a Resolved key +// yields a hop name; NotCapability, Indeterminate, and a resource-bound abort all become null (NOT +// a hop), so a genuinely runtime key (`globalThis[runtimeKey]`) is never folded into a self-hop and +// stays outside the frozen boundary. A FRESH memo isolates the wider hop ceiling from the shared +// network-member memo (a declaration classified under one ceiling is never read back under the +// other). Binder/shadowing authority over the base identifier stays with `isFreeGlobalReceiver`. +const netHopName = (node: ts.Expression, checker: ts.TypeChecker): string | null => { + try { + const key = netResolveKey(node, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_GLOBAL_RECEIVER_LENGTH); + return key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (error instanceof NetResolveAbort) return null; + throw error; + } +}; + +// DDR-NET-STATIC-KEY-PARITY (F2) — classify a destructuring property KEY (declaration binding +// element OR assignment ObjectLiteral property) with the SAME three-state discipline as a member +// key: a plain identifier / string-literal name is its own text (Resolved); a computed key +// (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) is resolved off the binder by `netResolveKey` at the +// network-member ceiling into Resolved / NotCapability / Indeterminate. At a proven free-global +// receiver the caller applies the frozen NET policy — Resolved(capability) and Indeterminate DENY, +// Resolved(other) / NotCapability ALLOW — so an indeterminate destructuring key fails closed +// exactly like an indeterminate member key. Any other name form (numeric/private) is Indeterminate. +const netDestructuringKey = (keyNode: ts.Node, checker: ts.TypeChecker): NetKey => { + if (ts.isComputedPropertyName(keyNode)) { + try { + return netResolveKey(keyNode.expression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } + } + if (ts.isIdentifier(keyNode) || ts.isStringLiteralLike(keyNode)) return { kind: 'resolved', value: keyNode.text }; + return NET_INDETERMINATE; +}; + /** * NET — reject outbound network egress, decided by TypeScript BINDER identity * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, @@ -1717,13 +1772,44 @@ const usesOutboundNetwork = (source: string): boolean => { found = true; } } - // (3) destructuring a network global off a FREE global receiver: `const { fetch } = globalThis`. + // (3) DECLARATION destructuring a network global off a FREE global receiver: + // `const { fetch } = globalThis`, `const { fetch: f } = globalThis.globalThis`. The source + // KEY (`propertyName ?? name`) is classified with the SAME resolver as a member key + // (DDR-NET-STATIC-KEY-PARITY F2): a plain/quoted key is its own text, a computed static key + // (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) folds off the binder. At the proven free-global + // receiver Resolved(capability) and Indeterminate DENY (an indeterminate key fails closed, + // just like a member key), Resolved(other)/NotCapability ALLOW. Capability is extracted here, + // at the destructuring; `f` is not followed onward through value flow. if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent) && ts.isVariableDeclaration(node.parent.parent)) { - const key = node.propertyName ?? node.name; - const name = ts.isIdentifier(key) ? key.text : ts.isStringLiteralLike(key) ? key.text : null; const initializer = node.parent.parent.initializer; - if (name !== null && NETWORK_GLOBAL_NAMES.has(name) && initializer !== undefined && isFreeGlobalReceiver(initializer, checker, sourceFile)) { - found = true; + if (initializer !== undefined && isFreeGlobalReceiver(initializer, checker, sourceFile)) { + const key = netDestructuringKey(node.propertyName ?? node.name, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + } + } + // (4) ASSIGNMENT destructuring a network global off a FREE global receiver: + // `({ fetch: f } = globalThis)`, `({ fetch } = globalThis.globalThis)`. The target is an + // ObjectLiteralExpression (Shorthand/PropertyAssignment), not a binding pattern, so branch + // (3) does not see it (DDR-NET-STATIC-KEY-PARITY F2). Same classification and same + // free-global fail-closed policy; bound to the `= globalThis` right-hand receiver, so an + // unrelated `({ fetch: f } = obj)` and a shadowed-global receiver stay allowed. + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isFreeGlobalReceiver(node.right, checker, sourceFile)) { + const target = binderUnwrap(node.left); + if (ts.isObjectLiteralExpression(target)) { + for (const prop of target.properties) { + const keyNode = ts.isShorthandPropertyAssignment(prop) ? prop.name : ts.isPropertyAssignment(prop) ? prop.name : undefined; + if (keyNode === undefined) continue; + const key = netDestructuringKey(keyNode, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + } } } ts.forEachChild(node, visit); @@ -5290,6 +5376,88 @@ describe('D3 host recognizes global-object self-reference chains as global recei } }); +// --------------------------------------------------------------------------- +// DDR-NET-STATIC-KEY-PARITY — one bounded static-key abstraction across the NET free-global +// surface. F1: a global-object SELF-REFERENCE hop whose element-access key is statically provable +// (`globalThis['global' + 'This']`, `const k = 'globalThis'; globalThis[k]`, +// `` globalThis[`glob` + 'alThis'] ``) denotes the real global, exactly like the already-recognized +// dotted / string-literal hop, so a network member read off it is egress. F2: a network global +// extracted by DESTRUCTURING ASSIGNMENT (`({ fetch: f } = globalThis)`) — an ObjectLiteral target, +// not a binding pattern — is caught at the same free-global receiver as the declaration form, and a +// computed static destructuring key folds while an indeterminate one fails closed. The hop key, the +// member key, the declaration destructuring key, and the assignment destructuring key all resolve +// through the SAME binder resolver (`netResolveKey`); binder/shadowing authority stays with the base +// identifier, and a genuinely runtime key stays outside the frozen boundary. Capability is rejected +// at extraction — `f` is not followed onward through value flow. +// --------------------------------------------------------------------------- +describe('D3 host folds static keys in self-reference hops and destructuring assignments (DDR-NET-STATIC-KEY-PARITY)', () => { + // ---- F1: self-reference hop static-key folding — DENY -------------------------------------- + const f1Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '1. a dotted self-hop globalThis.globalThis.fetch', source: `globalThis.globalThis.fetch('https://evil.example/');` }, + { form: "2. a literal-element self-hop globalThis['globalThis'].fetch", source: `globalThis['globalThis'].fetch('https://evil.example/');` }, + { form: "3. a concatenated self-hop globalThis['global' + 'This'].fetch", source: `globalThis['global' + 'This'].fetch('https://evil.example/');` }, + { form: '4. a const-key self-hop globalThis[k].fetch', source: `const k = 'globalThis';\nglobalThis[k].fetch('https://evil.example/');` }, + { form: '5. a template/static self-hop globalThis[`glob` + `alThis`].fetch', source: 'globalThis[`glob` + `alThis`].fetch("https://evil.example/");' }, + { form: "6. a mixed multi-hop globalThis['global' + 'This'].globalThis.fetch", source: `globalThis['global' + 'This'].globalThis.fetch('https://evil.example/');` }, + ]; + for (const { form, source } of f1Reject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F1: self-reference hop static-key folding — ALLOW (preserve) -------------------------- + // 7. a genuinely runtime hop key resolves to null (NOT a hop), so the receiver is never folded + // into a self-reference global. `globalThis[runtimeKey]` off a bare free global is denied by + // the FROZEN indeterminate-member-key policy (unchanged); the hop-resolver contract is shown + // here off a non-global receiver, where an unresolved key is correctly not treated as a hop. + const f1Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '7. a genuinely runtime hop key (not folded, receiver not a self-global)', source: `declare const holder: any;\ndeclare const runtimeKey: string;\nvoid holder[runtimeKey].fetch('https://evil.example/');` }, + { form: '8. a shadowed globalThis base (binder says local)', source: `function f(globalThis: { globalThis: { fetch(x: string): void } }): void {\n globalThis['global' + 'This'].fetch('https://evil.example/');\n}\nvoid f;` }, + { form: "9. an unrelated object base unrelated['global' + 'This'].fetch", source: `const unrelated = { globalThis: { fetch: (x: string) => x } };\nvoid unrelated['global' + 'This'].fetch('x');` }, + { form: "10. a folded NON-self-reference hop key globalThis['craf' + 'ty'].fetch", source: `globalThis['craf' + 'ty'].fetch('https://evil.example/');` }, + ]; + for (const { form, source } of f1Allow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- F2: destructuring (declaration + assignment) parity — DENY ---------------------------- + const f2Reject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '11. a declaration shorthand const { fetch } = globalThis', source: `const { fetch } = globalThis;\nvoid fetch('https://evil.example/');` }, + { form: '12. a declaration alias const { fetch: f } = globalThis', source: `const { fetch: f } = globalThis;\nvoid f('https://evil.example/');` }, + { form: '13. an assignment alias ({ fetch: f } = globalThis)', source: `let f: (u: string) => unknown;\n({ fetch: f } = globalThis);\nvoid f;` }, + { form: '14. an assignment shorthand through a self-hop ({ fetch } = globalThis.globalThis)', source: `let fetch: (u: string) => unknown;\n({ fetch } = globalThis.globalThis);\nvoid fetch;` }, + { form: "15. a computed static assignment key ({ ['fe' + 'tch']: f } = globalThis)", source: `let f: (u: string) => unknown;\n({ ['fe' + 'tch']: f } = globalThis);\nvoid f;` }, + { form: "16. a computed static declaration key const { ['fe' + 'tch']: f } = globalThis", source: `const { ['fe' + 'tch']: f } = globalThis;\nvoid f('https://evil.example/');` }, + { form: '17. an indeterminate destructuring key on a proven free-global (fail-closed)', source: `declare const runtimeKey: string;\nconst { [runtimeKey]: f } = globalThis;\nvoid f;` }, + ]; + for (const { form, source } of f2Reject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F2: destructuring (declaration + assignment) parity — ALLOW (preserve) ---------------- + const f2Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '18. an unrelated receiver assignment ({ fetch: f } = cfg)', source: `const cfg: { fetch?: (u: string) => unknown } = {};\nlet f: ((u: string) => unknown) | undefined;\n({ fetch: f } = cfg);\nvoid f;` }, + { form: '19. a shadowed global receiver const { fetch } = globalThis (local param)', source: `function f(globalThis: { fetch: (u: string) => unknown }): void {\n const { fetch } = globalThis;\n void fetch('local');\n}\nvoid f;` }, + ]; + for (const { form, source } of f2Allow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // Indeterminate fail-closed disposition is preserved for the ASSIGNMENT form too (F2 #17 twin). + it('rejects an indeterminate assignment key on a proven free-global fail-closed', () => { + expect( + usesOutboundNetwork(`declare const runtimeKey: string;\nlet f: unknown;\n({ [runtimeKey]: f } = globalThis);\nvoid f;`), + ).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // NET free-global receiver is a LOCAL-VALUE-SHADOW question, not a declaration-kind list // (D3-CX-POLICY-NET-SHADOW-LOCAL). A global-receiver name (globalThis/window/self/global) From 87f1adf4d3e4a9fede9651a8e0203fd85d16e1da Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 08:34:31 +0200 Subject: [PATCH 21/35] test(cockpit): restrict createServer capability escapes --- tests/cockpit-host/purity.test.ts | 169 +++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 3 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index cd63860..c0c3ea1 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1125,6 +1125,49 @@ const isHttpNsSafePosition = (node: ts.Node): boolean => { return false; }; +// DDR-CREATE-SERVER-ALIAS-POLICY (Option A — positive-model restriction). The one permitted +// node:http value capability, `createServer`, may itself REMAIN only in the smallest approved +// direct-call forms: as the callee of a call (`http.createServer(...)`, `createServer(...)`, +// `mk(...)`), or in a type / non-runtime position. Every other position — a variable/const +// initializer, an assignment right-hand side, a call ARGUMENT, a return, an array/object +// element or spread value — FORWARDS or STORES the constructor capability itself and is a +// forbidden escape, decided at THIS occurrence (the same shape as `isHttpNsSafePosition`; no +// alias/value-flow tracking — a receiving binding such as `start`/`cs` is never classified). +// The returned Server object is a SEPARATE value (capability NONE), so the CALL RESULT +// (`const server = http.createServer(...)`) sits in none of these positions and is untouched. +// Unwraps only the same finite transparent wrappers (paren / as / satisfies / non-null / await). +const isCreateServerSafePosition = (node: ts.Node): boolean => { + let cur: ts.Node = node; + for (;;) { + const parent = cur.parent as ts.Node | undefined; + if ( + parent !== undefined && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isAwaitExpression(parent)) + ) { + cur = parent; + continue; + } + break; + } + const p = cur.parent as ts.Node | undefined; + if (p === undefined) return false; + if ((ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === cur) return true; + if ( + ts.isTypeQueryNode(p) || + ts.isTypeReferenceNode(p) || + ts.isQualifiedName(p) || + ts.isTypeOfExpression(p) || + ts.isImportTypeNode(p) + ) { + return true; + } + return false; +}; + // A declaration EMITS a runtime value binding — and so can shadow a runtime global — only // when it is neither ambient (`declare …`, which emits nothing) nor a type-only form // (interface / type alias / type-only import). This is the minimal runtime-emission @@ -1735,12 +1778,18 @@ const usesOutboundNetwork = (source: string): boolean => { const visit = (node: ts.Node): void => { // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. if (isDynamicNodeHttpImport(node)) found = true; - // (1) member/element access: an HTTP_NS receiver is permitted ONLY for `createServer`; + // (1) member/element access: an HTTP_NS receiver is permitted ONLY for `createServer`, + // and that `createServer` access must itself sit in a direct-call position + // (DDR-CREATE-SERVER-ALIAS-POLICY) — a stored/forwarded `http.createServer` escapes; // a network global (`fetch`/`WebSocket`) off a FREE global receiver is rejected. if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { if (classifyHttpExpression(node.expression, checker) === 'HTTP_NS') { const member = binderMemberName(node); - if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) found = true; + if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) { + found = true; // a non-createServer (outbound client) member off the namespace + } else if (!isCreateServerSafePosition(node)) { + found = true; // the createServer constructor forwarded/stored out of a direct call + } } // F1/P2/P1 — classify the member key (Resolved / NotCapability / Indeterminate) off the binder // and decide it ONLY at a binder-verified free-global receiver. Resolved(capability) DENY; @@ -1764,6 +1813,11 @@ const usesOutboundNetwork = (source: string): boolean => { const cap = classifyHttpSymbol(symbol, checker); if (cap === 'HTTP_CLIENT') found = true; if (cap === 'HTTP_NS' && !isHttpNsSafePosition(node)) found = true; + // A createServer-classified identifier (named/renamed import or directly-destructured + // binding) is the constructor capability itself: it may be READ only in a direct-call + // position (DDR-CREATE-SERVER-ALIAS-POLICY). A read that stores/forwards it + // (`const start = createServer`) escapes — decided here, without tracking `start`. + if (cap === 'CREATE_SERVER' && !isCreateServerSafePosition(node)) found = true; // A bare network global (`fetch`/`WebSocket`) whose only in-file declarations emit no // runtime value (an ambient `declare const fetch`, a type-only import) still reaches // the runtime global — reject it. A real local shadow (const/function/class/…) does not. @@ -1772,6 +1826,21 @@ const usesOutboundNetwork = (source: string): boolean => { found = true; } } + // (2b) an EXPORT specifier forwarding the createServer constructor out of the module + // (`export { createServer }`) is a non-call escape position for the capability + // (DDR-CREATE-SERVER-ALIAS-POLICY). One binder hop to the local target's own + // classification — no module-graph or value-flow analysis. A type-only specifier, a + // re-export from node:http (no local target → NONE, left to export confinement), and + // the exported CALL RESULT (`export const server = http.createServer(...)`, whose + // binding is NONE) are all untouched, so `exportsHttpCapability` stays unchanged. + if ( + ts.isExportSpecifier(node) && + !node.isTypeOnly && + !node.parent.parent.isTypeOnly && + classifyHttpSymbol(checker.getExportSpecifierLocalTargetSymbol(node), checker) === 'CREATE_SERVER' + ) { + found = true; + } // (3) DECLARATION destructuring a network global off a FREE global receiver: // `const { fetch } = globalThis`, `const { fetch: f } = globalThis.globalThis`. The source // KEY (`propertyName ?? name`) is classified with the SAME resolver as a member key @@ -4930,7 +4999,10 @@ describe('D3 host forbids escape of the node:http namespace capability (D3-CX-PO const escapeAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a createServer access on the namespace', source: `import * as http from 'node:http';\nhttp.createServer(() => {});` }, { form: 'a statically-keyed createServer access', source: `import * as http from 'node:http';\nhttp['createServer'](() => {});` }, - { form: 'extracting createServer to a const via access', source: `import * as http from 'node:http';\nconst cs = http.createServer;\ncs(() => {});` }, + // NOTE: 'extracting createServer to a const via access' + // (`const cs = http.createServer; cs(() => {});`) was RECLASSIFIED out of this permissive + // family — CREATE_SERVER constructor forwarding is outside the positive authority model + // (DDR-CREATE-SERVER-ALIAS-POLICY). It now asserts REJECT in the createServer describe below. { form: 'a createServer destructuring', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, { form: 'a type reference to the namespace member', source: `import * as http from 'node:http';\nexport type S = http.Server;\nhttp.createServer(() => {});` }, { form: 'a typeof type query of the namespace', source: `import * as http from 'node:http';\nexport type T = typeof http;\nhttp.createServer(() => {});` }, @@ -4943,6 +5015,97 @@ describe('D3 host forbids escape of the node:http namespace capability (D3-CX-PO } }); +// --------------------------------------------------------------------------- +// NET createServer constructor positive-model restriction (DDR-CREATE-SERVER-ALIAS-POLICY, +// Codex finding PRRT_kwDOTzqfcs6dkiEI / P1 "Track createServer aliases before inspecting +// handlers", Option A). The one permitted node:http value capability — the inbound-server +// constructor `createServer` — may itself REMAIN only in the smallest approved direct-call +// forms (a call callee, or a type position); any position that forwards or stores the +// constructor value (an alias, an assignment target, a call argument, a return, an array/ +// object element or spread, an export of the binding) is a rejected escape, decided at THAT +// occurrence. This REDUCES accepted authority: it introduces no alias/value-flow tracking — a +// receiving binding such as `start`/`cs` is never classified, so the closure of the Codex +// witness (`const start = http.createServer; start(req => …req.socket…)`) comes from +// `http.createServer` being stored, not from following `start`. The returned Server object is +// a separate NONE value, so the CALL RESULT (`const server = http.createServer(...)`, its +// `.listen(...)`, and its export) is untouched. Binder identity remains authoritative, so an +// unrelated or shadowed local `createServer` stays an ordinary local. +// --------------------------------------------------------------------------- +describe('D3 host restricts the createServer constructor to direct-call positions (DDR-CREATE-SERVER-ALIAS-POLICY)', () => { + const createServerReject: readonly { readonly form: string; readonly source: string }[] = [ + // 1 — const alias from http.createServer (also the reclassified NET-ESCAPE permissive case: + // `const cs = http.createServer; cs(() => {})` previously asserted ALLOW). + { form: 'a const alias of http.createServer', source: `import * as http from 'node:http';\nconst start = http.createServer;\nstart(() => {});` }, + { form: 'the reclassified permissive alias-then-call case', source: `import * as http from 'node:http';\nconst cs = http.createServer;\ncs(() => {});` }, + // 2 — alias of a directly-extracted (destructured) createServer. + { form: 'a const alias of a destructured createServer', source: `import * as http from 'node:http';\nconst { createServer } = http;\nconst start = createServer;\nstart(() => {});` }, + // 3 — post-declaration assignment of the constructor. + { form: 'a post-declaration assignment of http.createServer', source: `import * as http from 'node:http';\nlet start;\nstart = http.createServer;\nstart(() => {});` }, + // 4 — call-argument forwarding of the constructor. + { form: 'forwarding http.createServer as a call argument', source: `import * as http from 'node:http';\ndeclare function run(x: unknown): void;\nrun(http.createServer);` }, + // 5 — returning the constructor. + { form: 'returning http.createServer', source: `import * as http from 'node:http';\nexport function g(): unknown {\n return http.createServer;\n}` }, + // 6 — array storage of the constructor. + { form: 'storing http.createServer in an array', source: `import * as http from 'node:http';\nconst x = [http.createServer];\nvoid x;` }, + // 7 — object storage of the constructor (property value and spread+property). + { form: 'storing http.createServer in an object property', source: `import * as http from 'node:http';\nconst x = { start: http.createServer };\nvoid x;` }, + { form: 'storing http.createServer in a spread object property', source: `import * as http from 'node:http';\nconst base = {};\nconst x = { ...base, start: http.createServer };\nvoid x;` }, + // 8 — export of the constructor capability (named import re-exported; destructured re-exported). + { form: 'exporting a named createServer import binding', source: `import { createServer } from 'node:http';\nexport { createServer };` }, + { form: 'exporting a destructured createServer binding', source: `import * as http from 'node:http';\nconst { createServer } = http;\nexport { createServer };` }, + ]; + for (const { form, source } of createServerReject) { + it(`REJECTS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // 9 — the exact Codex witness: the constructor is forwarded to `start`, then the handler + // reaches the underlying socket through a runtime-computed key. Rejection is because + // `http.createServer` is stored, NOT because `start` is tracked. + it('REJECTS the exact Codex runtime-key handler witness', () => { + const source = [ + `import http from 'node:http';`, + ``, + `const start = http.createServer;`, + ``, + `start(req => {`, + ` const key = (req.url ?? '').slice(1);`, + ` const s = (req as any)[key];`, + ``, + ` s.destroy();`, + ` setTimeout(() => s.connect(80, 'example.com'), 50);`, + `});`, + ].join('\n'); + expect(usesOutboundNetwork(source)).toBe(true); + }); + + const createServerAllow: readonly { readonly form: string; readonly source: string }[] = [ + // 10–11 — the direct namespace call and the static-element call. + { form: 'a direct http.createServer(...) call', source: `import http from 'node:http';\nhttp.createServer(() => {});` }, + { form: `a direct http['createServer'](...) call`, source: `import * as http from 'node:http';\nhttp['createServer'](() => {});` }, + // 12–13 — a direct named import call and a renamed named import call. + { form: 'a direct named createServer import call', source: `import { createServer } from 'node:http';\ncreateServer(() => {});` }, + { form: 'a direct renamed named createServer import call', source: `import { createServer as mk } from 'node:http';\nmk(() => {});` }, + // 14–15 — a direct destructuring call and a renamed destructuring call. + { form: 'a direct destructured createServer call', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, + { form: 'a direct renamed destructured createServer call', source: `import * as http from 'node:http';\nconst { createServer: mk } = http;\nmk(() => {});` }, + // 16–17 — storing and exporting the CALL RESULT (the Server object, capability NONE). + { form: 'storing the createServer call RESULT', source: `import http from 'node:http';\nconst server = http.createServer(() => {});\nvoid server;` }, + { form: 'exporting the createServer call RESULT', source: `import http from 'node:http';\nexport const server = http.createServer(() => {});` }, + // 18–19 — an unrelated local createServer, and a shadowed parameter createServer. + { form: 'an unrelated local object method createServer', source: `const local = {\n createServer() {},\n};\nlocal.createServer();` }, + { form: 'a shadowed parameter named createServer', source: `function f(createServer: () => void) {\n createServer();\n}\nvoid f;` }, + // 20 — the returned Server object's own methods (e.g. listen) are unchanged. + { form: 'the returned server.listen(...) behavior', source: `import http from 'node:http';\nconst server = http.createServer(() => {});\nserver.listen(4317);` }, + ]; + for (const { form, source } of createServerAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + // --------------------------------------------------------------------------- // NET module-boundary export confinement (D3-CX-POLICY-NET-EXPORT). The privileged // node:http capability may not cross the D3 module boundary: no re-export from From cdc5291cae4ad16aa1fa98239616f05c3f33b314 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 14:11:30 +0200 Subject: [PATCH 22/35] test(cockpit): bind socket acquisition keys by identity Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F13C6haW4MQViiewXWeVUw --- tests/cockpit-host/purity.test.ts | 151 ++++++++++++++++++++++++------ 1 file changed, 122 insertions(+), 29 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index c0c3ea1..17496df 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1343,12 +1343,18 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou const isCreateServerCall = (node: ts.Node): boolean => ts.isCallExpression(node) && classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; - // DDR-B: the SAME bounded static-string resolver the RC/HA member-name check uses, so a - // statically constructed socket-acquisition key (`server['o' + 'n']`, - // `const k = 'on'; server[k]`) resolves to its name. Receiver-independent, fail-closed: a - // genuinely runtime key resolves to null and stays outside the proof (frozen boundary). NET's - // own binder-based key resolver is untouched; this map is local to SOCK. - const constMap = collectStringConsts(sourceFile); + // DDR-NET-STATIC-KEY-PARITY (SOCK): socket acquisition keys are resolved by TypeScript BINDER + // identity via the shared bounded `netResolveKey` (see `sockResolveKey`) — the SAME resolver NET's + // member/hop/destructuring keys use — never by the whole-file text-keyed `collectStringConsts`/ + // `staticStringOf`. A Resolved socket-acquisition NAME is rejected on any receiver + // (`server['o' + 'n']`, `const k = 'on'; server[k]`), and a shadowing same-text `const` in an + // unrelated scope can no longer make a binder-pinned key unresolved (the scope-insensitive + // fail-open). An Indeterminate (genuinely runtime / resource-abort) key stays outside the proof, + // except on a createServer req/res param where RULE A2 fails closed. One declaration-keyed memo, + // fresh per traversal and scoped to the socket ceiling, is shared by every SOCK key resolution so a + // const chain reused across many accesses is resolved once (bounded work); per-key `seen`/`budget` + // stay local (cycle detection + the per-key hop ceiling). + const sockMemo = new Map(); // Pass 1 (RULE A2 support) — collect the createServer request/response parameter symbols. // Direct identifier params only; a destructured param `({ socket })` is a RULE A binding @@ -1391,19 +1397,26 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // visited here regardless of how (or whether) they are later invoked. if (ts.isPropertyAccessExpression(node) && STATIC_SOCKET_ACQUISITION_NAMES.has(node.name.text)) found = true; // RULE A (b) — GLOBAL static-computed socket-acquisition NAME `['socket']`/`['connection']`/ - // `['on']`/…/`['setTimeout']` (any receiver, e.g. `server['on']('connection', …)`); else - // RULE A2 fails closed on an indeterminate computed key when the receiver is a - // createServer param. The A2 (receiverIsReqRes) branch is unchanged. + // `['on']`/…/`['setTimeout']` (any receiver, e.g. `server['on']('connection', …)`), + // resolved by BINDER identity (`sockResolveKey` → `netResolveKey`), never by identifier + // text. Resolved(socket name) REJECTS on any receiver; Resolved(other) and NotCapability are + // non-matches; Indeterminate falls to the unchanged RULE A2 fail-closed branch. if (ts.isElementAccessExpression(node)) { const arg = node.argumentExpression; - const resolved = staticStringOf(arg, constMap); - // RULE A extension (DDR-B): a statically-resolvable socket-acquisition NAME, ANY receiver - // (`server['o' + 'n']`, `const k='on'; server[k]`, `` server[`socket`] ``). - if (resolved !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(resolved)) found = true; - // RULE A2 (unchanged): a non-string-literal computed key on a createServer req/res param - // fails closed. The trigger stays keyed on `!isStringLiteralLike`, so A2's fail-closed - // surface is byte-for-byte what it was — the resolver only ADDS global name rejections. - else if (!ts.isStringLiteralLike(arg) && receiverIsReqRes(node.expression)) found = true; + const key = sockResolveKey(arg, checker, sockMemo); + // RULE A extension: a binder-resolvable socket-acquisition NAME, ANY receiver + // (`server['o' + 'n']`, `const k='on'; server[k]`, `` server[`socket`] ``), even with an + // unrelated shadowing `const k` in another scope — binder identity pins the exact key. + if (key.kind === 'resolved') { + if (STATIC_SOCKET_ACQUISITION_NAMES.has(key.value)) found = true; + } + // RULE A2 (unchanged): an INDETERMINATE (runtime / resource-abort) computed key on a + // createServer req/res param fails closed. The trigger stays keyed on `!isStringLiteralLike`, + // so A2's fail-closed surface is byte-for-byte what it was — the resolver only ADDS global + // name rejections; a NotCapability key never reaches here. + else if (key.kind === 'indeterminate' && !ts.isStringLiteralLike(arg) && receiverIsReqRes(node.expression)) { + found = true; + } } // RULE A (c) — GLOBAL socket-acquisition NAME destructuring in any object binding pattern // (variable, parameter, nested, callback): `{ socket }` / `{ connection }` / @@ -1411,13 +1424,19 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // (the static source KEY is what is banned, never the local binding name). if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { const keyNode = node.propertyName ?? node.name; - // RULE A extension (DDR-B): a statically-resolvable COMPUTED destructuring key - // (`{ ['o'+'n']: h }`, `{ [k]: h }` with `const k='on'`) resolves via the SAME - // resolver; a plain key keeps its existing staticKeyText path. The separate A2 - // destructuring branch below is untouched, so its fail-closed surface is preserved. - const name = ts.isComputedPropertyName(keyNode) - ? staticStringOf(keyNode.expression, constMap) - : staticKeyText(keyNode); + // RULE A extension: a COMPUTED destructuring key (`{ ['o'+'n']: h }`, `{ [k]: h }` with + // `const k='on'`) is resolved by the SAME binder-aware `sockResolveKey` used for member + // access — binder identity, never identifier text — so a shadowing same-text `const` + // elsewhere cannot flip it. A plain identifier/string key is its own literal text + // (`staticKeyText`, purely syntactic). The separate A2 destructuring branch below is + // untouched, so its fail-closed surface is preserved. + let name: string | null = null; + if (ts.isComputedPropertyName(keyNode)) { + const key = sockResolveKey(keyNode.expression, checker, sockMemo); + if (key.kind === 'resolved') name = key.value; + } else { + name = staticKeyText(keyNode); + } if (name !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(name)) found = true; } // RULE A2 (destructuring) — an INDETERMINATE computed binding key destructured DIRECTLY @@ -1746,6 +1765,36 @@ const netDestructuringKey = (keyNode: ts.Node, checker: ts.TypeChecker): NetKey return NET_INDETERMINATE; }; +// DDR-NET-STATIC-KEY-PARITY (SOCK, F1) — SOCK's static socket acquisition-key resolution is +// CONSOLIDATED onto the SAME bounded binder-aware `netResolveKey` the NET member/hop/destructuring +// keys use, replacing the scope-insensitive whole-file text-keyed `collectStringConsts`/ +// `staticStringOf` path. Binder identity is required at the key AND at every const-initializer hop, +// so the exact occurrence is resolved by the TypeScript binder: an unrelated sibling/nested same-text +// declaration in another scope can no longer make a statically-known key unresolved — the fail-open +// that let `const k='on'; server[k](...)` escape when an unrelated `const k='noop'` existed elsewhere +// (the whole-file collector saw two `k` bindings and demoted the key to UNKNOWN even though the +// binder pins the exact `k='on'`). The socket ceiling is the longest name in +// STATIC_SOCKET_ACQUISITION_NAMES (`prependOnceListener` = 19), DERIVED from the set so a `+`-fold to +// any socket name (`'set' + 'Timeout'`) resolves rather than being pruned as NotCapability. A fresh +// `seen`/`budget` per call keeps active-path cycle detection and the per-key hop ceiling local; the +// caller's shared per-traversal `memo` (declaration-keyed, socket-ceiling-scoped) keeps a reused +// chain O(N + M); a resource-bound abort is caught and becomes Indeterminate. Mechanism +// CONSOLIDATION only — no new resolver, alias/capability/taint propagation, assignment following, or +// new policy family; NET's own resolver and the shared RC/HA text helpers are untouched. +const MAX_SOCKET_MEMBER_LENGTH = Math.max(...[...STATIC_SOCKET_ACQUISITION_NAMES].map((name) => name.length)); +const sockResolveKey = ( + node: ts.Expression, + checker: ts.TypeChecker, + memo: Map, +): NetKey => { + try { + return netResolveKey(node, checker, new Set(), memo, { spent: 0 }, 0, MAX_SOCKET_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } +}; + /** * NET — reject outbound network egress, decided by TypeScript BINDER identity * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, @@ -5451,9 +5500,9 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } - // --- DDR-B: statically CONSTRUCTED socket-acquisition KEYS resolve via the shared - // static-string resolver, any receiver — MUST REJECT. `server['o' + 'n']` is the - // reported reproduction. A genuinely runtime key resolves to null and stays outside. --- + // --- DDR-B: statically CONSTRUCTED socket-acquisition KEYS resolve via the binder-aware + // `sockResolveKey` (`netResolveKey`), any receiver — MUST REJECT. `server['o' + 'n']` is the + // reported reproduction. A genuinely runtime key is Indeterminate and stays outside. --- const ddrBReject: readonly { readonly form: string; readonly source: string }[] = [ { form: "the reported server['o' + 'n']('connection', …) reproduction", source: wrapperHead + `server['o' + 'n']('connection', (s: { destroy(): void; connect(p: number, h: string): void }) => {\n s.destroy();\n setTimeout(() => s.connect(80, 'example.com'), 50);\n});` }, { form: "a literal server['on']", source: wrapperHead + `server['on']('connection', () => {});` }, @@ -5472,8 +5521,8 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 } // --- DDR-B: the frozen runtime-computed boundary stays OUTSIDE the proof — MUST ALLOW. - // A key that is not statically resolvable (a call result, an ambient runtime name) is - // unchanged; closing it would need alias/type/whole-program flow, deliberately excluded. --- + // A key the binder cannot pin to a static string (a call result, an ambient runtime name) is + // Indeterminate; closing it would need alias/type/whole-program flow, deliberately excluded. --- const ddrBAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a genuinely runtime server[runtimeKey] (declared, unresolvable)', source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, { form: 'a call-result key server[String(4317)] (unresolvable)', source: wrapperHead + `void server[String(4317)];` }, @@ -5484,6 +5533,50 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(false); }); } + + // --- CODEX F1 BINDER-KEY MATRIX (SOCK consolidation): the socket acquisition key is now resolved + // by TypeScript BINDER identity (`sockResolveKey` → `netResolveKey`), so an unrelated shadowing + // same-text `const` in another scope NEVER makes a binder-pinned key unresolved the way the + // whole-file text collector did. M2/M4/M6 are the regression witnesses: under the old + // text-keyed `collectStringConsts`/`staticStringOf` the sibling `const` demoted the key to + // UNKNOWN (fail-open); the binder pins the exact declaration and REJECTS. MUST REJECT. --- + const binderKeyReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: "M1: const k='on'; server[k]('connection', …)", source: wrapperHead + `const k = 'on';\nserver[k]('connection', () => {});` }, + { form: "M2: M1 with an unrelated shadowing const k='noop' (regression witness)", source: wrapperHead + `const k = 'on';\nserver[k]('connection', () => {});\nfunction unrelated(): void {\n const k = 'noop';\n void k;\n}\nvoid unrelated;` }, + { form: "M3: const key='socket'; req[key]", source: handler(`const key = 'socket';\nvoid req[key];`) }, + { form: "M4: M3 with an unrelated shadowing const key='x' (regression witness)", source: handler(`const key = 'socket';\nfunction unrelated(): void {\n const key = 'x';\n void key;\n}\nvoid req[key];\nvoid unrelated;`) }, + { form: "M5: const k='on'; const { [k]: h } = server", source: wrapperHead + `const k = 'on';\nconst { [k]: h } = server;\nvoid h;` }, + { form: "M6: M5 with an unrelated shadowing const k='noop' (regression witness)", source: wrapperHead + `const k = 'on';\nfunction unrelated(): void {\n const k = 'noop';\n void k;\n}\nconst { [k]: h } = server;\nvoid h;\nvoid unrelated;` }, + { form: "M7: server['o' + 'n']('connection', …)", source: wrapperHead + `server['o' + 'n']('connection', () => {});` }, + { form: "M8: const k='connection'; server[k]", source: wrapperHead + `const k = 'connection';\nvoid server[k];` }, + { form: "M9: const k='setTimeout'; server[k](…)", source: wrapperHead + `const k = 'setTimeout';\nserver[k](0, () => {});` }, + { form: "M10: direct literal server['on']('connection', …)", source: wrapperHead + `server['on']('connection', () => {});` }, + // M13 — the RULE A2 fail-closed floor is preserved: a genuinely runtime key on a createServer + // req/res param is DENIED (Indeterminate + receiverIsReqRes). + { form: "M13: req[runtimeKey] stays RULE A2 fail-closed on the handler param", source: handler(`declare const runtimeKey: string;\nvoid req[runtimeKey];`) }, + ]; + for (const { form, source } of binderKeyReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- CODEX F1 BINDER-KEY MATRIX (preserve): a runtime key on an unrelated receiver stays OUTSIDE + // the frozen proof, a provably-harmless key is allowed, and — resolved by the SAME binder + // identity — a harmless key is NEVER flipped by a shadowing socket-named sibling const + // (M16 exercises binder identity in the ALLOW direction). MUST ALLOW. --- + const binderKeyAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "M11: server[runtimeKey] on an unrelated receiver stays outside the proof", source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, + { form: "M12: server[String(4317)] stays outside the static proof", source: wrapperHead + `void server[String(4317)];` }, + { form: "M14: req['method'] stays allowed", source: handler(`void req['method'];`) }, + { form: "M15: harmless static server['listen'] stays allowed", source: wrapperHead + `void server['listen'];` }, + { form: "M16: a harmless key is not flipped by a shadowing socket-named sibling const", source: wrapperHead + `const k = 'listen';\nfunction unrelated(): void {\n const k = 'on';\n void k;\n}\nvoid server[k];\nvoid unrelated;` }, + ]; + for (const { form, source } of binderKeyAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } }); // --------------------------------------------------------------------------- From 2d86df6f8847227207be595196a449377c98ad47 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 20:13:42 +0200 Subject: [PATCH 23/35] fix(cockpit): close global capability acquisition gaps Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XTM9stRsCTgdXcgudcMamk --- tests/cockpit-host/purity.test.ts | 322 +++++++++++++++++++++++++++--- 1 file changed, 296 insertions(+), 26 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 17496df..00a35bb 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1795,6 +1795,107 @@ const sockResolveKey = ( } }; +// DDR-NET-STATIC-KEY-PARITY (nested authority) — the self-reference HOP NAME a DESTRUCTURING key +// denotes, resolved by the SAME bounded binder resolver used for a member-access hop (`netHopName`) +// but reading a binding/object-literal KEY node instead of an element-access argument: a plain +// identifier / string-literal key is its own text, a computed key (`{ ['global' + 'This']: … }`, +// `{ [k]: … }` with `const k = 'globalThis'`) folds off the binder, capped at +// `MAX_GLOBAL_RECEIVER_LENGTH` (10 — a hop can fold to `globalThis`, one above the 9-char network +// member ceiling). Only a Resolved key yields a hop name; NotCapability, Indeterminate, and a +// resource-bound abort all become null (NOT a hop), so a genuinely runtime intermediate key never +// starts/continues authority and stays outside the frozen boundary. This is the destructuring twin +// of `netHopName`; it introduces NO new resolver — it reuses `netResolveKey` exactly like +// `netDestructuringKey`, only at the wider self-hop ceiling that `MAX_NETWORK_MEMBER_LENGTH` (9) +// would prune a 10-char self-reference name out of. +const netDestructuringHopName = (keyNode: ts.Node, checker: ts.TypeChecker): string | null => { + if (ts.isComputedPropertyName(keyNode)) { + try { + const key = netResolveKey(keyNode.expression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_GLOBAL_RECEIVER_LENGTH); + return key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (error instanceof NetResolveAbort) return null; + throw error; + } + } + if (ts.isIdentifier(keyNode) || ts.isStringLiteralLike(keyNode)) return keyNode.text; + return null; +}; + +// DDR-NET-STATIC-KEY-PARITY (nested authority) — whether free-global-receiver authority REACHES an +// object binding PATTERN, resolved STRUCTURALLY over the finite binding AST (never value flow): +// - a TOP-LEVEL pattern (its container is the variable declaration) has authority iff the +// declaration's initializer is a binder-proven free global receiver (`isFreeGlobalReceiver`); +// - a NESTED pattern (its container is an OUTER binding element) has authority iff (a) the outer +// element's own pattern already has authority AND (b) the outer element's KEY resolves — through +// the SAME binder-aware key machinery (`netDestructuringHopName`) — to a global self-reference +// name in `GLOBAL_RECEIVER_NAMES`, exactly as `globalThis.globalThis` re-denotes the real global. +// Authority therefore CONTINUES only through a proven self-hop key; an intermediate key that is a +// non-self-hop name (`foo`), indeterminate, or a non-namespace receiver stops it (allow), mirroring +// how a shadowed/runtime hop demotes a member-access receiver. Finite: the recursion strips one +// binding-pattern nesting level per step and terminates at the variable declaration; it walks only +// binding-pattern parent links, never a value graph. +const objectPatternHasFreeGlobalAuthority = ( + pattern: ts.ObjectBindingPattern, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, +): boolean => { + const container = pattern.parent; + if (ts.isVariableDeclaration(container)) { + return container.initializer !== undefined && isFreeGlobalReceiver(container.initializer, checker, sourceFile); + } + if (ts.isBindingElement(container) && ts.isObjectBindingPattern(container.parent)) { + if (!objectPatternHasFreeGlobalAuthority(container.parent, checker, sourceFile)) return false; + const hop = netDestructuringHopName(container.propertyName ?? container.name, checker); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop); + } + return false; +}; + +// DDR-NET-REFLECT-GET (F1) — whether `Reflect` is the binder-proven UNSHADOWED built-in intrinsic. +// Reflect is a DISTINCT intrinsic, deliberately NOT a member of `GLOBAL_RECEIVER_NAMES`: it is +// recognized only here, and only when the binder resolves the identifier to NO local runtime value +// binding, exactly the `isFreeGlobalReceiver` identifier rule reused verbatim. A local +// `const Reflect = { get() { … } }` (or any runtime binding of the name) is therefore an ordinary +// object and its `.get` is NOT the built-in — identifier text alone never qualifies. +const isFreeReflect = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const e = binderUnwrap(expr); + if (!ts.isIdentifier(e) || e.text !== 'Reflect') return false; + const symbol = checker.getSymbolAtLocation(e); + if (symbol === undefined) return true; + return !hasLocalRuntimeShadow(symbol, sourceFile); +}; + +// DDR-NET-REFLECT-GET (F1) — whether a call callee is a DIRECT built-in `Reflect.get` member: a +// dotted `Reflect.get` or a static-string element access `Reflect['get']` / `Reflect['g' + 'et']`, +// with the `get` name resolved by the SAME `netMemberKey` machinery (never identifier text), off a +// binder-proven unshadowed `Reflect`. A runtime/aliased member key (`Reflect[k]`), an alias of +// Reflect (`const R = Reflect; R.get(…)`), or a shadowed Reflect all fail this — no alias, no +// call/apply/bind, no wrapper: one statically identifiable built-in member. +const isBuiltinReflectGetCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const memberKey = netMemberKey(c, checker, new Map()); + if (!(memberKey.kind === 'resolved' && memberKey.value === 'get')) return false; + return isFreeReflect(c.expression, checker, sourceFile); +}; + +// DDR-NET-REFLECT-GET (F1) — classify the KEY ARGUMENT of a `Reflect.get(receiver, key)` call with +// the SAME three-state discipline as a computed member key: a string literal / substitution-free +// template / `+`-fold resolves to its value; an identifier resolves through the binder-proven unique +// `const` chain (`const k = 'fetch'; Reflect.get(globalThis, k)`); a runtime/ambient/undeclared key +// (`declare const runtimeKey; Reflect.get(globalThis, runtimeKey)`) or a resource-bound abort is +// Indeterminate. The key is a VALUE expression (not a binding key), so — unlike `netDestructuringKey` +// — a bare identifier is resolved, never taken as its own text; this reuses `netResolveKey` exactly +// like `netMemberKey`'s element-access branch, at the network-member ceiling. +const netReflectGetKey = (keyArg: ts.Expression, checker: ts.TypeChecker): NetKey => { + try { + return netResolveKey(keyArg, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } +}; + /** * NET — reject outbound network egress, decided by TypeScript BINDER identity * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, @@ -1824,6 +1925,43 @@ const usesOutboundNetwork = (source: string): boolean => { const netMemo = new Map(); netResolveVisits = 0; let found = false; + // Branch (4) helper — walk an object-literal ASSIGNMENT target with free-global authority already + // established for THIS object literal (the top-level call is guarded by the `= ` + // receiver; a nested call is reached only through a proven self-hop key below). This is the + // assignment twin of branch (3)'s `objectPatternHasFreeGlobalAuthority` recursion: each property's + // source KEY is classified for a network capability at the network ceiling, and authority CONTINUES + // into a nested object-literal value only through a self-reference hop key (resolved at the wider + // self-hop ceiling by `netDestructuringHopName`). Finite: it descends only nested object-literal + // targets, one per pattern level, and terminates when no nested object literal remains. + const scanFreeGlobalAssignmentTarget = (target: ts.ObjectLiteralExpression): void => { + for (const prop of target.properties) { + let keyNode: ts.Node | undefined; + let valueNode: ts.Expression | undefined; + if (ts.isShorthandPropertyAssignment(prop)) { + keyNode = prop.name; + } else if (ts.isPropertyAssignment(prop)) { + keyNode = prop.name; + valueNode = prop.initializer; + } else { + continue; // spread / accessor / method — not a destructuring target property + } + const key = netDestructuringKey(keyNode, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + // Authority continues into a nested object-literal target ONLY through a self-reference hop key + // (a GLOBAL_RECEIVER_NAMES name resolved at the self-hop ceiling), mirroring branch (3). + if (valueNode !== undefined) { + const nested = binderUnwrap(valueNode); + if (ts.isObjectLiteralExpression(nested)) { + const hop = netDestructuringHopName(keyNode, checker); + if (hop !== null && GLOBAL_RECEIVER_NAMES.has(hop)) scanFreeGlobalAssignmentTarget(nested); + } + } + } + }; const visit = (node: ts.Node): void => { // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. if (isDynamicNodeHttpImport(node)) found = true; @@ -1890,17 +2028,22 @@ const usesOutboundNetwork = (source: string): boolean => { ) { found = true; } - // (3) DECLARATION destructuring a network global off a FREE global receiver: - // `const { fetch } = globalThis`, `const { fetch: f } = globalThis.globalThis`. The source - // KEY (`propertyName ?? name`) is classified with the SAME resolver as a member key - // (DDR-NET-STATIC-KEY-PARITY F2): a plain/quoted key is its own text, a computed static key - // (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) folds off the binder. At the proven free-global - // receiver Resolved(capability) and Indeterminate DENY (an indeterminate key fails closed, - // just like a member key), Resolved(other)/NotCapability ALLOW. Capability is extracted here, - // at the destructuring; `f` is not followed onward through value flow. - if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent) && ts.isVariableDeclaration(node.parent.parent)) { - const initializer = node.parent.parent.initializer; - if (initializer !== undefined && isFreeGlobalReceiver(initializer, checker, sourceFile)) { + // (3) DECLARATION destructuring a network global off a FREE global receiver, including + // RECURSIVE NESTED object binding patterns (DDR-NET-STATIC-KEY-PARITY, nested authority): + // `const { fetch } = globalThis`, `const { fetch: f } = globalThis.globalThis`, + // `const { globalThis: { fetch: f } } = globalThis.globalThis`. Free-global authority begins + // at the declaration initializer and CONTINUES into a nested pattern ONLY through a + // self-reference hop key (`objectPatternHasFreeGlobalAuthority`) — `{ globalThis: … }` off a + // global re-denotes the real global, exactly like `globalThis.globalThis`, while `{ foo: … }` + // does not. At an authoritative pattern this element's source KEY (`propertyName ?? name`) is + // classified with the SAME resolver as a member key: a plain/quoted key is its own text, a + // computed static key (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) folds off the binder. + // Resolved(capability) and Indeterminate DENY (an indeterminate key fails closed, just like a + // member key), Resolved(other)/NotCapability ALLOW. Capability is extracted here, at the + // destructuring; the bound name is not followed onward through value flow. Structural and + // finite — authority is proven by walking finite binding-pattern parents, never a value graph. + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + if (objectPatternHasFreeGlobalAuthority(node.parent, checker, sourceFile)) { const key = netDestructuringKey(node.propertyName ?? node.name, checker); if (key.kind === 'resolved') { if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; @@ -1909,24 +2052,40 @@ const usesOutboundNetwork = (source: string): boolean => { } } } - // (4) ASSIGNMENT destructuring a network global off a FREE global receiver: - // `({ fetch: f } = globalThis)`, `({ fetch } = globalThis.globalThis)`. The target is an - // ObjectLiteralExpression (Shorthand/PropertyAssignment), not a binding pattern, so branch - // (3) does not see it (DDR-NET-STATIC-KEY-PARITY F2). Same classification and same - // free-global fail-closed policy; bound to the `= globalThis` right-hand receiver, so an + // (4) ASSIGNMENT destructuring a network global off a FREE global receiver, including RECURSIVE + // NESTED object patterns (DDR-NET-STATIC-KEY-PARITY, assignment parity): + // `({ fetch: f } = globalThis)`, `({ fetch } = globalThis.globalThis)`, + // `({ globalThis: { fetch: f } } = globalThis.globalThis)`. The target is an + // ObjectLiteralExpression (Shorthand/PropertyAssignment), not a binding pattern, so branch (3) + // does not see it. `scanFreeGlobalAssignmentTarget` mirrors branch (3)'s structural recursion + // for the object-literal AST: the same leaf key classification and free-global fail-closed + // policy, with authority continuing into a nested object-literal value ONLY through a + // self-reference hop key — declaration and assignment forms have equivalent finite authority + // semantics where their AST forms correspond. Bound to the `=` right-hand receiver, so an // unrelated `({ fetch: f } = obj)` and a shadowed-global receiver stay allowed. if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isFreeGlobalReceiver(node.right, checker, sourceFile)) { const target = binderUnwrap(node.left); - if (ts.isObjectLiteralExpression(target)) { - for (const prop of target.properties) { - const keyNode = ts.isShorthandPropertyAssignment(prop) ? prop.name : ts.isPropertyAssignment(prop) ? prop.name : undefined; - if (keyNode === undefined) continue; - const key = netDestructuringKey(keyNode, checker); - if (key.kind === 'resolved') { - if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; - } else if (key.kind === 'indeterminate') { - found = true; // fail-closed at a proven free-global receiver - } + if (ts.isObjectLiteralExpression(target)) scanFreeGlobalAssignmentTarget(target); + } + // (5) a direct built-in `Reflect.get(, )` acquisition of a network + // global (DDR-NET-REFLECT-GET, F1): `Reflect.get(globalThis.globalThis, 'fetch')(...)`. + // Reflect must be binder-proven UNSHADOWED (a local `const Reflect = { get() {} }` is an + // ordinary object — `isBuiltinReflectGetCallee`); the RECEIVER argument must be a binder-proven + // free global (`isFreeGlobalReceiver`); the KEY argument is classified by the SAME + // `netResolveKey` as a computed member key (`netReflectGetKey`) — a bare identifier is + // resolved through its const chain, never taken as text. Resolved(capability)/Indeterminate + // DENY (fail-closed), Resolved(other)/NotCapability ALLOW. One statically + // identifiable built-in call: NO alias of Reflect or of get, NO call/apply/bind chain, NO + // wrapper, NO value-flow after acquisition. + if (ts.isCallExpression(node) && isBuiltinReflectGetCallee(node.expression, checker, sourceFile)) { + const recvArg = node.arguments[0]; + const keyArg = node.arguments[1]; + if (recvArg !== undefined && keyArg !== undefined && isFreeGlobalReceiver(recvArg, checker, sourceFile)) { + const key = netReflectGetKey(keyArg, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver } } } @@ -5714,6 +5873,117 @@ describe('D3 host folds static keys in self-reference hops and destructuring ass }); }); +// --------------------------------------------------------------------------- +// NET bounded P1 mechanism repair (PR #64): reflective built-in acquisition and NESTED +// destructuring authority (DDR-NET-REFLECT-GET / DDR-NET-STATIC-KEY-PARITY nested authority). +// The frozen invariant — a statically identifiable network-global capability may not be acquired +// from a binder-proven free-global receiver through a SUPPORTED FINITE ACQUISITION FORM, decided AT +// THE ACQUISITION SITE — is extended by exactly three finite forms, without any taint / alias / +// value-flow expansion: +// F1 — a direct built-in `Reflect.get(, )` (Reflect a distinct intrinsic, +// binder-proven unshadowed; the key a binder-resolved static string; fail-closed on an +// indeterminate key). No alias of Reflect/get, no call/apply/bind, no wrapper. +// F2 — recursive NESTED DECLARATION binding patterns, authority continuing into a nested pattern +// ONLY through a self-reference hop key (`{ globalThis: { fetch } }` off a global), never +// through a non-self-hop key (`{ foo: { fetch } }`). +// assignment parity — the same recursive authority for object-DESTRUCTURING ASSIGNMENT targets, +// where their AST forms correspond to the declaration form. +// Authority propagation is STRUCTURAL within the finite binding/destructuring AST only: once the +// binding pattern (or the object-literal target) ends, propagation ends. The scanner still makes NO +// literal-runtime-no-egress claim (aliased Reflect.get, alias chains, wrappers, Proxy/getter +// semantics, cross-module flow all remain honest, unsupported gaps). +// --------------------------------------------------------------------------- +describe('D3 host closes reflective and nested-destructuring free-global acquisition (PR #64 bounded P1)', () => { + // ---- F1: direct built-in Reflect.get — DENY ----------------------------------------------- + const reflectReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '1. the exact witness Reflect.get(globalThis.globalThis, "fetch")(...)', source: `Reflect.get(globalThis.globalThis, 'fetch')('https://example.com/');` }, + { form: '2. a const-key Reflect.get(globalThis, k) folding to fetch', source: `const k = 'fetch';\nReflect.get(globalThis, k)('https://example.com/');` }, + { form: '3. a concatenated key Reflect.get(globalThis, "fe" + "tch")', source: `Reflect.get(globalThis, 'fe' + 'tch')('https://example.com/');` }, + { form: '4. Reflect.get(globalThis, "WebSocket") acquiring the second global', source: `void new (Reflect.get(globalThis, 'WebSocket'))('wss://example.com/');` }, + { form: '5. a static-element callee Reflect["get"](globalThis, "fetch")', source: `Reflect['get'](globalThis, 'fetch')('https://example.com/');` }, + { form: '6. a plain globalThis receiver Reflect.get(globalThis, "fetch")', source: `Reflect.get(globalThis, 'fetch')('https://example.com/');` }, + ]; + for (const { form, source } of reflectReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // Indeterminate Reflect.get key on a proven free global fails CLOSED. + it('rejects Reflect.get(globalThis, runtimeKey) fail-closed on an indeterminate key', () => { + expect( + usesOutboundNetwork(`declare const runtimeKey: string;\nReflect.get(globalThis, runtimeKey);`), + ).toBe(true); + }); + + // ---- F1: direct built-in Reflect.get — ALLOW (preserve) ----------------------------------- + const reflectAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '7. Reflect.get on an ordinary local object (non-global receiver)', source: `const local = { fetch() {} };\nReflect.get(local, 'fetch')();` }, + { form: '8. a user-shadowed Reflect (non-built-in reflection)', source: `const Reflect = {\n get() {\n return () => undefined;\n },\n};\nReflect.get(globalThis, 'fetch')();` }, + { form: '9. Reflect.get acquiring a NON-network member off a free global', source: `void Reflect.get(globalThis, 'crypto');` }, + { form: '10. a Reflect.get ALIAS is an unsupported honest gap (not this mechanism)', source: `const rget = Reflect.get;\nrget(globalThis, 'fetch')('https://example.com/');` }, + { form: '11. an aliased receiver Reflect.get(g, "fetch") where g = globalThis (unsupported gap)', source: `const g = globalThis;\nReflect.get(g, 'fetch')('https://example.com/');` }, + ]; + for (const { form, source } of reflectAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- F2: recursive NESTED DECLARATION destructuring — DENY -------------------------------- + const nestedDeclReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '12. the exact witness const { globalThis: { fetch: f } } = globalThis.globalThis', source: `const {\n globalThis: { fetch: f },\n} = globalThis.globalThis;\nf('https://example.com/');` }, + { form: '13. a nested pattern off a bare global const { globalThis: { fetch } } = globalThis', source: `const {\n globalThis: { fetch },\n} = globalThis;\nvoid fetch('https://example.com/');` }, + { form: '14. a three-level self-hop nest const { globalThis: { window: { fetch } } } = globalThis', source: `const {\n globalThis: { window: { fetch } },\n} = globalThis;\nvoid fetch('https://example.com/');` }, + { form: '15. the second global nested const { globalThis: { WebSocket: W } } = globalThis', source: `const {\n globalThis: { WebSocket: W },\n} = globalThis;\nvoid new W('wss://example.com/');` }, + { form: '16. a computed self-hop intermediate const { ["global" + "This"]: { fetch: f } } = globalThis', source: `const {\n ['global' + 'This']: { fetch: f },\n} = globalThis;\nf('https://example.com/');` }, + { form: '17. a nested indeterminate leaf key fails closed', source: `declare const runtimeKey: string;\nconst {\n globalThis: { [runtimeKey]: f },\n} = globalThis;\nvoid f;` }, + { form: '18. an indeterminate INTERMEDIATE key fails closed at the free global', source: `declare const runtimeKey: string;\nconst {\n [runtimeKey]: { fetch: f },\n} = globalThis;\nvoid f;` }, + ]; + for (const { form, source } of nestedDeclReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F2: recursive NESTED DECLARATION destructuring — ALLOW (preserve) -------------------- + const nestedDeclAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '19. a NON-self-hop intermediate const { foo: { fetch: f } } = globalThis', source: `const {\n foo: { fetch: f },\n} = globalThis as unknown as { foo: { fetch: (u: string) => unknown } };\nvoid f;` }, + { form: '20. a nested pattern off a non-global receiver const { globalThis: { harmless } } = localObject', source: `const localObject = { globalThis: { harmless: 1 } };\nconst {\n globalThis: { harmless },\n} = localObject;\nvoid harmless;` }, + { form: '21. a nested self-hop to a NON-network leaf const { globalThis: { crypto } } = globalThis', source: `const {\n globalThis: { crypto },\n} = globalThis;\nvoid crypto;` }, + { form: '22. a self-hop nest under a SHADOWED global base (binder says local)', source: `function f(globalThis: { globalThis: { fetch: (u: string) => unknown } }): void {\n const {\n globalThis: { fetch: g },\n } = globalThis;\n void g('local');\n}\nvoid f;` }, + ]; + for (const { form, source } of nestedDeclAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- assignment parity: recursive NESTED ASSIGNMENT destructuring — DENY ------------------- + const nestedAssignReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '23. the exact witness ({ globalThis: { fetch: f } } = globalThis.globalThis)', source: `let f: (u: string) => unknown;\n({\n globalThis: { fetch: f },\n} = globalThis.globalThis);\nvoid f;` }, + { form: '24. a nested assignment off a bare global ({ globalThis: { fetch } } = globalThis)', source: `let fetch: (u: string) => unknown;\n({\n globalThis: { fetch },\n} = globalThis);\nvoid fetch;` }, + { form: '25. a computed self-hop intermediate ({ ["global" + "This"]: { fetch: f } } = globalThis)', source: `let f: (u: string) => unknown;\n({\n ['global' + 'This']: { fetch: f },\n} = globalThis);\nvoid f;` }, + { form: '26. a nested indeterminate leaf key assignment fails closed', source: `declare const runtimeKey: string;\nlet f: unknown;\n({\n globalThis: { [runtimeKey]: f },\n} = globalThis);\nvoid f;` }, + ]; + for (const { form, source } of nestedAssignReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- assignment parity: recursive NESTED ASSIGNMENT destructuring — ALLOW (preserve) ------- + const nestedAssignAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '27. a NON-self-hop intermediate ({ foo: { fetch: f } } = globalThis)', source: `let f: (u: string) => unknown;\n({\n foo: { fetch: f },\n} = globalThis as unknown as { foo: { fetch: (u: string) => unknown } });\nvoid f;` }, + { form: '28. a nested assignment off an unrelated receiver ({ globalThis: { fetch: f } } = obj)', source: `const obj = { globalThis: { fetch: (u: string) => u } };\nlet f: (u: string) => unknown;\n({\n globalThis: { fetch: f },\n} = obj);\nvoid f;` }, + ]; + for (const { form, source } of nestedAssignAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } +}); + // --------------------------------------------------------------------------- // NET free-global receiver is a LOCAL-VALUE-SHADOW question, not a declaration-kind list // (D3-CX-POLICY-NET-SHADOW-LOCAL). A global-receiver name (globalThis/window/self/global) From 17d4f71af415981d9c97783cd98fe90934aa1f93 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 22:25:43 +0200 Subject: [PATCH 24/35] fix(cockpit): close registrar assignment acquisition Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QEL8ik1ZCmm33Ej982kReA --- tests/cockpit-host/purity.test.ts | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 00a35bb..d778cf3 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1386,6 +1386,44 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou }; let found = false; + // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — the assignment-AST twin of the + // RULE A (c) per-binding-element key check, walked over the finite ObjectLiteralExpression + // destructuring TARGET of an `=`, but DELIBERATELY SCOPED to the receiver-independent + // delivery/registrar-member family (`SOCKET_DELIVERY_MEMBERS`: on/once/addListener/ + // prependListener/prependOnceListener/setTimeout), NOT the full `STATIC_SOCKET_ACQUISITION_NAMES` + // set. `({ on: register } = server)` extracts the registrar exactly like `const { on: register } + // = server`, but its target is an ObjectLiteralExpression (Shorthand/PropertyAssignment), not a + // BindingElement, so RULE A (c) never saw it. Each target KEY is resolved by the SAME + // binder-aware machinery A (c) uses — a plain/quoted key is its own text (`staticKeyText`), a + // computed key (`{ ['o'+'n']: … }`, `{ [k]: … }`) folds off the binder (`sockResolveKey`) — and a + // RESOLVED delivery/registrar name REJECTS receiver-independently (the `.on` / `const { on }` + // registrar bans are already receiver-independent, so no server identity is tracked). The + // socket/connection CAPABILITY names are intentionally EXCLUDED here: their assignment-extraction + // policy stays the receiver-sensitive req/res-bound RULE A2 assignment branch below (the accepted + // D3-CX-CODEX-ASSIGN "no global broadening" invariant — `({ socket } = unrelatedObject)` and + // `({ socket } = server)` remain allowed). Recurses ONLY into a nested ObjectLiteralExpression + // VALUE — the assignment twin of a nested binding pattern (`({ a: { on } } = x)`) — terminating at + // the finite AST depth with NO value flow, alias following, or receiver tracking. An INDETERMINATE + // computed key is NOT globally failed closed here (only a Resolved delivery name rejects); the + // req/res A2 branch below retains its own fail-closed behavior. + const scanSocketAssignmentTarget = (target: ts.ObjectLiteralExpression): void => { + for (const prop of target.properties) { + if (ts.isShorthandPropertyAssignment(prop)) { + if (SOCKET_DELIVERY_MEMBERS.has(prop.name.text)) found = true; + } else if (ts.isPropertyAssignment(prop)) { + let name: string | null = null; + if (ts.isComputedPropertyName(prop.name)) { + const key = sockResolveKey(prop.name.expression, checker, sockMemo); + if (key.kind === 'resolved') name = key.value; + } else { + name = staticKeyText(prop.name); + } + if (name !== null && SOCKET_DELIVERY_MEMBERS.has(name)) found = true; + const value = binderUnwrap(prop.initializer); + if (ts.isObjectLiteralExpression(value)) scanSocketAssignmentTarget(value); + } + } + }; const visit = (node: ts.Node): void => { // RULE A (a) — GLOBAL dotted socket-acquisition NAME: `.socket`/`.connection` or a delivery // member `.on`/`.once`/`.addListener`/`.prependListener`/`.prependOnceListener`/ @@ -1490,6 +1528,23 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } } } + // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — receiver-INDEPENDENT + // delivery/registrar-member NAME in an object DESTRUCTURING ASSIGNMENT target: + // `({ on: register } = server)` / `({ on } = server)` / `({ ['o'+'n']: h } = server)` / nested + // `({ a: { on } } = server)`. The target is an ObjectLiteralExpression (Shorthand/ + // PropertyAssignment), NOT a BindingElement, so RULE A (c) above does not see it, and the RULE + // A2 assignment branch just above is bound to req/res receivers + SOCKET_CAPABILITY_NAMES only. + // `scanSocketAssignmentTarget` closes the verified registrar-extraction gap by applying the + // receiver-independent SOCKET_DELIVERY_MEMBERS ban to the assignment AST — server identity is + // never tracked, exactly as `.on` / `const { on }` are already receiver-independent. The + // socket/connection CAPABILITY names are DELIBERATELY not broadened here: their assignment + // extraction stays the req/res-bound RULE A2 branch above (accepted D3-CX-CODEX-ASSIGN + // invariant). Only a RESOLVED delivery name rejects; an indeterminate computed key is left to + // the req/res-bound RULE A2 branch above (NOT globally failed closed). + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const target = binderUnwrap(node.left); + if (ts.isObjectLiteralExpression(target)) scanSocketAssignmentTarget(target); + } // RULE B (PROMOTED into RULE A's name family) — the event registrars and `setTimeout` are // now banned by NAME at RULE A (a)/(b)/(c) above, receiver- and position-independent, so // the former call-callee-only registrar ban is fully subsumed (a called `server.on(...)` @@ -5693,6 +5748,54 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } + // --- ASSIGNMENT-DESTRUCTURING PARITY (SOCK, DELIVERY/REGISTRAR members ONLY): `({ on: register } = + // server)` extracts the registrar off `server` exactly like the declaration twin + // `const { on: register } = server`, but the target is an ObjectLiteralExpression (Shorthand/ + // PropertyAssignment), not a BindingElement, so RULE A (c) did not see it and the req/res-bound + // RULE A2 assignment branch (SOCKET_CAPABILITY names, req/res receivers) skipped a `server` + // receiver. The new receiver-independent branch closes ONLY the delivery/registrar-member family + // (`SOCKET_DELIVERY_MEMBERS`: on/once/addListener/prependListener/prependOnceListener/setTimeout), + // resolved by the SAME `sockResolveKey`/`staticKeyText` as RULE A (c). The socket/connection + // CAPABILITY names are DELIBERATELY NOT broadened (accepted D3-CX-CODEX-ASSIGN invariant); see the + // allow block below. MUST REJECT. --- + const assignDestructureReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the reported ({ on: register } = server) registrar extraction + reconnect', source: wrapperHead + `let register: (this: unknown, e: string, cb: (s: { destroy(): void; connect(p: number, h: string): void }) => void) => void;\n({ on: register } = server as unknown as { on: typeof register });\nregister.call(server, 'connection', (socket) => {\n socket.destroy();\n setTimeout(() => socket.connect(80, 'example.com'), 50);\n});` }, + { form: 'a shorthand ({ on } = server)', source: wrapperHead + `let on: unknown;\n({ on } = server as unknown as { on: unknown });\nvoid on;` }, + { form: "a static-computed ({ ['on']: register } = server)", source: wrapperHead + `let register: unknown;\n({ ['on']: register } = server as unknown as { on: unknown });\nvoid register;` }, + { form: "a concatenated ({ ['o' + 'n']: register } = server)", source: wrapperHead + `let register: unknown;\n({ ['o' + 'n']: register } = server as unknown as Record);\nvoid register;` }, + { form: "a const-bound ({ [k]: register } = server) with const k = 'on'", source: wrapperHead + `const k = 'on';\nlet register: unknown;\n({ [k]: register } = server as unknown as Record);\nvoid register;` }, + { form: 'a setTimeout member ({ setTimeout: t } = server)', source: wrapperHead + `let t: unknown;\n({ setTimeout: t } = server as unknown as { setTimeout: unknown });\nvoid t;` }, + { form: 'a once member ({ once: h } = server)', source: wrapperHead + `let h: unknown;\n({ once: h } = server as unknown as { once: unknown });\nvoid h;` }, + { form: 'a nested ({ inner: { on: register } } = wrap) parity with RULE A (c)', source: wrapperHead + `let register: unknown;\nconst wrap = { inner: server } as unknown as { inner: { on: unknown } };\n({ inner: { on: register } } = wrap);\nvoid register;` }, + ]; + for (const { form, source } of assignDestructureReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- ASSIGNMENT-DESTRUCTURING PARITY (SOCK) — the DELIBERATE asymmetry: the new receiver-independent + // branch is scoped to DELIVERY/REGISTRAR members only, so socket/connection CAPABILITY assignment + // extraction keeps its accepted req/res-sensitive semantics (the D3-CX-CODEX-ASSIGN "no global + // broadening" invariant) — `({ socket } = unrelatedObject)` / `({ socket } = server)` / + // `({ connection } = unrelatedObject)` remain ALLOWED. The delivery branch also adds NO global + // fail-closed on indeterminate keys and NO receiver/alias/taint tracking, so a harmless key and an + // indeterminate key on an unrelated object stay allowed. MUST ALLOW. --- + const assignDestructureAllow: readonly { readonly form: string; readonly source: string }[] = [ + // The accepted D3-CX-CODEX-ASSIGN invariant, asserted adjacently here as a preservation guard. + { form: 'the preserved ({ socket: localSocket } = unrelatedObject) capability invariant', source: `const unrelatedObject = { socket: 123 };\nlet localSocket: unknown;\n({ socket: localSocket } = unrelatedObject);\nvoid localSocket;` }, + { form: 'a capability ({ socket: s } = server) stays req/res-bound (NOT delivery-broadened)', source: wrapperHead + `let s: unknown;\n({ socket: s } = server as unknown as { socket: unknown });\nvoid s;` }, + { form: 'a capability ({ connection: c } = unrelatedObject) is NOT globally banned', source: `const unrelatedObject = { connection: 1 };\nlet c: unknown;\n({ connection: c } = unrelatedObject);\nvoid c;` }, + { form: 'a harmless ({ harmless: x } = arbitraryObject)', source: `const arbitraryObject: Record = {};\nlet x: unknown;\n({ harmless: x } = arbitraryObject);\nvoid x;` }, + { form: 'a harmless shorthand ({ method } = arbitraryObject)', source: `const arbitraryObject: Record = {};\nlet method: unknown;\n({ method } = arbitraryObject);\nvoid method;` }, + { form: 'an INDETERMINATE key on an unrelated object stays outside the proof', source: `declare const runtimeKey: string;\nconst arbitraryObject: Record = {};\nlet x: unknown;\n({ [runtimeKey]: x } = arbitraryObject);\nvoid x;` }, + ]; + for (const { form, source } of assignDestructureAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + // --- CODEX F1 BINDER-KEY MATRIX (SOCK consolidation): the socket acquisition key is now resolved // by TypeScript BINDER identity (`sockResolveKey` → `netResolveKey`), so an unrelated shadowing // same-text `const` in another scope NEVER makes a binder-pinned key unresolved the way the From c698af0af24fb541269faf14226db881892c37a1 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 23:14:43 +0200 Subject: [PATCH 25/35] fix(cockpit): fold static template member keys Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QEL8ik1ZCmm33Ej982kReA --- tests/cockpit-host/purity.test.ts | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index d778cf3..cf599af 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1706,6 +1706,29 @@ const netResolveKey = ( if (left.value.length + right.value.length > maxLen) return NET_NOT_CAPABILITY; return { kind: 'resolved', value: left.value + right.value }; } + // A SUBSTITUTED template `` `o${'n'}` `` denotes the same static string as the `+`-fold and folds the + // SAME way: the head text, then for every span the resolved substitution EXPRESSION followed by that + // span's literal text — each substitution resolved through THIS resolver (string literal / `+`-fold / + // unique-const identity), reusing the same depth/visit/length bounds. A substitution-free template is + // a `NoSubstitutionTemplateLiteral` already handled by the `isStringLiteralLike` branch above; a + // `TemplateExpression` always carries ≥1 span. Length is bounded BEFORE each concat (an oversized + // result is provably NotCapability, so no oversized intermediate is materialized). If ANY substitution + // is indeterminate (runtime / mutable / ambient / unresolvable), the whole template is Indeterminate — + // no runtime coercion, no `toString`, no value flow. + if (ts.isTemplateExpression(n)) { + let value = n.head.text; + if (value.length > maxLen) return NET_NOT_CAPABILITY; + for (const span of n.templateSpans) { + const part = netResolveKey(span.expression, checker, seen, memo, budget, depth + 1, maxLen); + if (part.kind === 'indeterminate') return NET_INDETERMINATE; + if (part.kind === 'notCapability') return NET_NOT_CAPABILITY; // already too long; concat only grows it + if (value.length + part.value.length > maxLen) return NET_NOT_CAPABILITY; + value += part.value; + if (value.length + span.literal.text.length > maxLen) return NET_NOT_CAPABILITY; + value += span.literal.text; + } + return { kind: 'resolved', value }; + } if (ts.isIdentifier(n)) { // Resolve an identifier-ALIAS spine (`const a = b; const b = c; …`) ITERATIVELY, so a chain of // any length consumes O(1) native stack. Every declaration on the spine denotes the SAME key, so @@ -5748,6 +5771,49 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } + // --- DDR-B (STATIC-TEMPLATE PARITY): a socket-acquisition KEY spelled as a SUBSTITUTED template + // `` server[`o${'n'}`] `` denotes the same static string as `server['on']` / `server['o'+'n']`, + // but its key node is a `TemplateExpression` (not `StringLiteralLike`, not a `+` BinaryExpression, + // not an Identifier), so the bounded static-key resolver (`netResolveKey`, via `sockResolveKey`) + // previously fell through to Indeterminate — and an Indeterminate key rejects only on a tracked + // req/res receiver, so with an http.Server receiver the registrar escaped. A `TemplateExpression` + // is folded EXACTLY like the `+`-fold: `head.text` + resolved(span.expression) + span.literal.text + // for every span, each substitution resolved through the SAME binder-aware machinery (string + // literal / `+`-fold / unique-const identity), reusing the SAME depth/visit/length bounds. Every + // substitution must be independently bounded-static; if ANY is mutable/ambient/runtime the whole + // template stays Indeterminate (see the allow block). The no-substitution `` server[`on`] `` is + // already `StringLiteralLike` and handled above. MUST REJECT. --- + const ddrBTemplateReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the reported server[`o${\'n\'}`](\'connection\', …) registrar extraction + reconnect', source: wrapperHead + 'server[`o${\'n\'}`](\'connection\', (s: { destroy(): void; connect(p: number, h: string): void }) => {\n s.destroy();\n setTimeout(() => s.connect(80, \'example.com\'), 50);\n});' }, + { form: 'a whole-key substitution server[`${\'on\'}`]', source: wrapperHead + 'server[`${\'on\'}`](\'connection\', () => {});' }, + { form: 'a const-substitution server[`${a}n`] with const a = \'o\'', source: wrapperHead + 'const a = \'o\';\nserver[`${a}n`](\'connection\', () => {});' }, + { form: 'a two-const-substitution server[`${a}${b}`] with a=\'o\', b=\'n\'', source: wrapperHead + 'const a = \'o\';\nconst b = \'n\';\nserver[`${a}${b}`](\'connection\', () => {});' }, + { form: 'a delivery member server[`set${\'Timeout\'}`]', source: wrapperHead + 'void server[`set${\'Timeout\'}`];' }, + { form: 'a capability name server[`sock${\'et\'}`]', source: wrapperHead + 'void server[`sock${\'et\'}`];' }, + { form: 'a binder-pinned const under a same-text shadow in another scope server[`${a}n`]', source: wrapperHead + 'const a = \'o\';\nfunction other(): string {\n const a = \'zz\';\n return a;\n}\nvoid other;\nserver[`${a}n`](\'connection\', () => {});' }, + ]; + for (const { form, source } of ddrBTemplateReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- DDR-B (STATIC-TEMPLATE PARITY): the frozen indeterminate boundary is UNCHANGED — a template + // with ANY non-bounded-static substitution (runtime/ambient identifier, mutable `let` binding, + // unresolvable call) stays Indeterminate, so on a non-req/res receiver it is NOT flagged. Only a + // fully binder-static template folds. MUST ALLOW. --- + const ddrBTemplateAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a runtime-substituted server[`o${runtime}n`] (declared, unresolvable)', source: wrapperHead + 'declare const runtime: string;\nvoid server[`o${runtime}n`];' }, + { form: 'a mutable-binding server[`${a}n`] with let a = \'o\'', source: wrapperHead + 'let a = \'o\';\na = \'o\';\nvoid server[`${a}n`];' }, + { form: 'a call-result substitution server[`o${String(1)}`] (unresolvable)', source: wrapperHead + 'void server[`o${String(1)}`];' }, + { form: 'a harmless resolvable non-socket template server[`lis${\'ten\'}`]', source: wrapperHead + 'void server[`lis${\'ten\'}`];' }, + ]; + for (const { form, source } of ddrBTemplateAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + // --- ASSIGNMENT-DESTRUCTURING PARITY (SOCK, DELIVERY/REGISTRAR members ONLY): `({ on: register } = // server)` extracts the registrar off `server` exactly like the declaration twin // `const { on: register } = server`, but the target is an ObjectLiteralExpression (Shorthand/ From a6781d8a2859cce8282a61724770fedf3967972d Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 31 Aug 2026 23:55:08 +0200 Subject: [PATCH 26/35] fix(cockpit): reject forwarded global authority Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138GjJx3nbN2K7NhuMU2poe --- tests/cockpit-host/purity.test.ts | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index cf599af..5c9b396 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -696,6 +696,29 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { ) { found = true; } + // (e') forwarding a global-object SELF-REFERENCE value (`globalThis.globalThis`, + // `window['window']`, and chains of such hops) as a value — the SAME + // acquisition-site closure as (e), lifted from the bare identifier to the + // structural global receiver that (b)/(f) already recognize via `isGlobalReceiver`. + // `globalThis.globalThis` re-denotes the real global, so `const g = + // globalThis.globalThis; g.eval(...)` (or `g.fetch(...)`) is the same forwarded + // alias as `const g = globalThis`, only spelled through the self-hop that NET's + // `isFreeGlobalReceiver` already proves free — without this, rule (e) caught the + // bare identifier but the self-hop laundered the acquisition into an array + // element / object value / initializer / argument / return. A self-hop that + // DIRECTLY serves as the access object (`globalThis.globalThis.fetch(...)`, itself + // caught by NET; `globalThis.globalThis.console.log(...)`) is a direct member + // operation and preserved — only a self-hop escaping as a VALUE is rejected. No + // checker and no flow tracing: the receiver's identity is RC's existing structural + // name reservation (b)/(f), so a shadowed base is rejected exactly as (e) already + // rejects a shadowed `const g = globalThis`, and the escaped alias is never traced. + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + isGlobalReceiver(node) && + !servesAsAccessObject(node) + ) { + found = true; + } // (f) a computed element access on a recognized global receiver whose key is not // statically resolvable — a runtime-built key (`['e','v','a','l'].join('')`, // `String.fromCharCode(...)`) could acquire `eval`/`Function`/`process` off @@ -4540,6 +4563,81 @@ describe('D3 host RC global-object forwarding closure (D3-CX-POLICY-RC v3)', () }); }); +// RC v4 — global-object SELF-REFERENCE forwarding closure. v3 rejected forwarding a +// BARE global receiver (`const g = globalThis; g.eval(...)`), but the self-reference +// hop `globalThis.globalThis` — which `isGlobalReceiver` already recognizes as the +// real global for rules (b)/(f), and which NET's `isFreeGlobalReceiver` proves free — +// evaded rule (e), so laundering the acquisition through the self-hop +// (`const g = globalThis.globalThis; g.eval(...)` / `[globalThis.globalThis][0].fetch(...)`) +// slipped past every detector. v4 closes the parity gap at the exact forwarding site +// with NO alias tracing: the self-hop is rejected where it ESCAPES as a value, never by +// following the resulting alias. This is the same acquisition-site closure as v3, lifted +// from the bare identifier to the structural global receiver. Every witness typechecks +// under strict NodeNext. (This is where the network self-hop forwarding witness — a +// `.fetch` acquired off a forwarded `globalThis.globalThis` — is closed too: NET relies on +// this forwarding closure exactly as the NET detector's doc comment states, so the witness +// is denied here, before any free receiver can reach the `.fetch` member.) +// --------------------------------------------------------------------------- +describe('D3 host RC global-object self-reference forwarding closure (D3-CX-POLICY-RC v4)', () => { + const rejected: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'direct self-hop alias then eval', source: `const g = globalThis.globalThis;\ng.eval("import('../domain/actions.js')");` }, + { form: 'self-hop forwarded through an array then eval', source: `const g = [globalThis.globalThis][0]!;\ng.eval("import('../domain/actions.js')");` }, + { form: 'self-hop forwarded through an object then Function', source: `const box = { g: globalThis.globalThis };\nbox.g.Function('return 1')();` }, + { form: 'self-hop returned from a function then eval', source: `function obtain() { return globalThis.globalThis; }\nobtain().eval("import('../domain/actions.js')");` }, + { form: 'self-hop passed as a function argument', source: `function consume(x: unknown): void { void x; }\nconsume(globalThis.globalThis);` }, + { form: 'self-hop acquired via IIFE then eval', source: `const g = (() => globalThis.globalThis)();\ng.eval("import('../domain/actions.js')");` }, + { form: 'element-access self-hop alias then eval', source: `const g = globalThis['globalThis'];\ng.eval("import('../domain/actions.js')");` }, + { form: 'window.window self-hop alias then eval (type-valid synthetic)', source: `declare const window: typeof globalThis;\nconst g = window.window;\ng.eval("import('../domain/actions.js')");` }, + { form: 'two-hop self-reference chain forwarded then eval', source: `const g = globalThis.globalThis.globalThis;\ng.eval("import('../domain/actions.js')");` }, + // The exact Codex F2 network witness: a forwarded self-hop whose alias is used to + // reach the `fetch` global. Denied at the self-hop forwarding site (RC), which is + // the closure NET depends on — no free receiver ever reaches `.fetch`. + { form: 'network fetch off a self-hop forwarded through an array (Codex F2)', source: `const g = [globalThis.globalThis][0]!;\ng.fetch('https://example.com/');` }, + { form: 'network fetch off a direct self-hop alias', source: `const g = globalThis.globalThis;\ng.fetch('https://example.com/');` }, + ]; + for (const { form, source } of rejected) { + it(`rejects ${form}`, () => { + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // Preservation: a self-hop that DIRECTLY serves as a member-access receiver is a + // normal operation and stays accepted by RC — the network `.fetch` case is caught + // by NET (the direct-member detector), not by this forwarding closure, and a benign + // direct member (`.console`) is harmless. + it('preserves a direct self-hop member access', () => { + expect(usesRuntimeCodeGeneration(`globalThis.globalThis.console.log('x');`)).toBe(false); + expect(usesRuntimeCodeGeneration(`globalThis.globalThis.setTimeout(() => {}, 0);`)).toBe(false); + // A direct network member off the self-hop is not RC's concern; NET rejects it. + expect(usesRuntimeCodeGeneration(`globalThis.globalThis.fetch('https://example.com/');`)).toBe(false); + expect(usesOutboundNetwork(`globalThis.globalThis.fetch('https://example.com/');`)).toBe(true); + }); + + // Preservation: forwarding an ORDINARY local object that merely happens to expose an + // `eval`/`fetch` member is not a global acquisition and must remain accepted. + it('preserves forwarding of an unrelated local object', () => { + expect( + usesRuntimeCodeGeneration(`const box = { g: { eval(s: string) { return s; } } };\nbox.g.eval('x');`), + ).toBe(false); + expect( + usesOutboundNetwork(`const box = { g: { fetch(u: string) { return u; } } };\nbox.g.fetch('x');`), + ).toBe(false); + }); + + // The self-hop forwarding is rejected receiver-name-structurally (RC carries no + // TypeChecker), exactly as v3 rejects a shadowed bare `const g = globalThis`: a + // lexically-shadowed `globalThis` base is still denied when forwarded through the + // self-hop. NET (which IS checker-based) remains the shadow-aware detector for direct + // member access; this closure preserves v3's deliberate fail-closed name reservation. + it('rejects a shadowed self-hop forwarding, matching v3 bare-identifier parity', () => { + expect( + usesRuntimeCodeGeneration( + `function h(globalThis: { globalThis: { eval: (s: string) => void } }) {\n const g = globalThis.globalThis;\n g.eval('x');\n}`, + ), + ).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // F1/F2 — the shared static-const resolver (collectStringConsts) must be // scope-sensitive and total. These suites drive the resolver THROUGH the RC/HA From 4835b19798029ff84152018970b64e10eeca623e Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 1 Sep 2026 01:26:31 +0200 Subject: [PATCH 27/35] fix(cockpit): fold forwarded static global keys Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138GjJx3nbN2K7NhuMU2poe --- tests/cockpit-host/purity.test.ts | 111 +++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 5c9b396..796fab0 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -353,14 +353,25 @@ const selfReferenceHopName = ( // A structural global receiver: a bare global-object identifier, OR a self-reference member // (name in GLOBAL_RECEIVER_NAMES) read off another structural global receiver — so -// `globalThis.globalThis`, `globalThis.window`, `window.window` are all recognized. Finite: +// `globalThis.globalThis`, `globalThis.window`, `window.window` are all recognized. An +// ELEMENT-access hop key is folded by the SAME bounded static-key resolver the rest of this +// checker-free detector uses (`staticStringOf`: literal / `+`-concat / `const`-key chains), so +// `globalThis['global' + 'This']` and `const k = 'globalThis'; globalThis[k]` are recognized hops +// on parity with the dotted / string-literal form — the forwarding closure (rule (e′)) then rejects +// a static-key self-hop forwarded as a value, not only a literal one. A runtime-built key folds to +// null and stays outside the boundary; binder/shadowing authority stays with the base identifier +// (this reserves the receiver names structurally — see the RC v3/v4 forwarding suites). Finite: // each recursion strips one member-access layer off `node.expression`. -const isGlobalReceiver = (node: ts.Expression): boolean => { +const isGlobalReceiver = (node: ts.Expression, constMap: ReadonlyMap): boolean => { const n = unwrapExpr(node); if (ts.isIdentifier(n)) return GLOBAL_RECEIVER_NAMES.has(n.text); - if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { + if (ts.isPropertyAccessExpression(n)) { const hop = selfReferenceHopName(n); - return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression, constMap); + } + if (ts.isElementAccessExpression(n)) { + const hop = staticStringOf(n.argumentExpression, constMap); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression, constMap); } return false; }; @@ -667,7 +678,7 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { // (a) `.constructor` on any receiver — the function-constructor chain. if (member === 'constructor') found = true; // (b) `.eval` / `.Function` off a global receiver. - else if (member !== null && RC_PRIMITIVE_NAMES.has(member) && isGlobalReceiver(node.expression)) { + else if (member !== null && RC_PRIMITIVE_NAMES.has(member) && isGlobalReceiver(node.expression, constMap)) { found = true; } } @@ -714,7 +725,7 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { // rejects a shadowed `const g = globalThis`, and the escaped alias is never traced. if ( (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && - isGlobalReceiver(node) && + isGlobalReceiver(node, constMap) && !servesAsAccessObject(node) ) { found = true; @@ -727,7 +738,7 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { // `globalThis['ev' + 'al']`) is not caught here (the latter is caught by (b)). if ( ts.isElementAccessExpression(node) && - isGlobalReceiver(node.expression) && + isGlobalReceiver(node.expression, constMap) && staticStringOf(node.argumentExpression, constMap) === null ) { found = true; @@ -781,10 +792,10 @@ const HIDDEN_BUILTIN_METHODS: ReadonlySet = new Set(['getBuiltinModule', const isProcessValue = (node: ts.Node, constMap: ReadonlyMap): boolean => { if (ts.isIdentifier(node)) return node.text === 'process' && isValueReference(node); if (ts.isPropertyAccessExpression(node)) { - return node.name.text === 'process' && isGlobalReceiver(node.expression); + return node.name.text === 'process' && isGlobalReceiver(node.expression, constMap); } if (ts.isElementAccessExpression(node)) { - return staticStringOf(node.argumentExpression, constMap) === 'process' && isGlobalReceiver(node.expression); + return staticStringOf(node.argumentExpression, constMap) === 'process' && isGlobalReceiver(node.expression, constMap); } return false; }; @@ -4638,6 +4649,88 @@ describe('D3 host RC global-object self-reference forwarding closure (D3-CX-POLI }); }); +// RC v5 — STATIC-KEY parity for the global self-reference forwarding closure. The v4 closure +// recognized a forwarded self-hop only through the checker-free `isGlobalReceiver`, which folded +// LITERAL keys only, so a bounded static-key self-hop forwarded as a value — +// `const g = globalThis['global' + 'This']; g.fetch(…)` or `const k = 'globalThis'; globalThis[k]` +// — folded to the benign receiver name `globalThis` and slipped past every guard (RC (f) saw the +// key as Resolved(other), not Indeterminate, and the literal-only self-hop resolver never +// recognized the hop). `isGlobalReceiver` now folds an element-access hop key through the SAME +// bounded static-key resolver the rest of this checker-free detector already uses (`staticStringOf`: +// literal / `+`-concat / `const`-key), so the forwarding closure (rule (e′)) recognizes the identical +// finite key grammar the NET self-hop mechanism does — reusing existing machinery, introducing no +// new resolver, and tracing no alias. A truly runtime key folds to null and stays outside the +// boundary; a template-substitution key stays Indeterminate and is already denied fail-closed by +// rule (f). Shadow/binder authority is unchanged: `isGlobalReceiver` reserves the receiver NAMES +// structurally (as v3/v4 already do), so a forwarded self-hop off a shadowed base is rejected on +// exact parity with the forwarded literal self-hop — while the DIRECT member path stays shadow-aware +// (see DDR-A). Every witness typechecks under strict NodeNext. +// --------------------------------------------------------------------------- +describe('D3 host RC static-key global forwarding parity (D3-CX-POLICY-RC v5)', () => { + const rejected: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a concat self-hop key forwarded then fetch (Codex witness)', source: `const g = globalThis['global' + 'This'];\ng.fetch('https://example.com/');` }, + { form: 'a const-string self-hop key forwarded then fetch', source: `const key = 'globalThis';\nconst g = globalThis[key];\ng.fetch('https://example.com/');` }, + { form: 'a bracket-literal self-hop key forwarded then fetch', source: `const g = globalThis['globalThis'];\ng.fetch('https://example.com/');` }, + { form: 'an inline template self-hop key forwarded then fetch (fail-closed via rule (f))', source: "const g = globalThis[`global${'This'}`];\ng.fetch('https://example.com/');" }, + { form: 'a const template self-hop key forwarded then fetch (fail-closed via rule (f))', source: "const key = `global${'This'}`;\nconst g = globalThis[key];\ng.fetch('https://example.com/');" }, + { form: 'a window concat self-hop forwarded then fetch (type-valid synthetic)', source: `declare const window: typeof globalThis;\nconst g = window['win' + 'dow'];\ng.fetch('https://example.com/');` }, + { form: 'a concat self-hop key forwarded then eval (codegen route parity)', source: `const g = globalThis['global' + 'This'];\ng.eval("import('../domain/actions.js')");` }, + // A runtime-computed key is NOT folded to a static self-hop; it stays denied only by the + // pre-existing Indeterminate (fail-closed) rule (f), never by the new static fold. + { form: 'a runtime-key access stays denied fail-closed (indeterminate, not folded)', source: `declare function rk(): string;\nconst g = globalThis[rk()];\ng.fetch('https://example.com/');` }, + ]; + for (const { form, source } of rejected) { + it(`rejects ${form}`, () => { + expect(usesRuntimeCodeGeneration(source)).toBe(true); + }); + } + + // The shadowed static-key forward is rejected on EXACT PARITY with the shadowed LITERAL forward + // (both are the RC structural name reservation, shadow-blind by adopted v3/v4 design) — the fix + // recognizes more key spellings, it does not change shadow handling. + it('rejects a shadowed static-key forward on parity with the shadowed literal forward', () => { + const shadowConcat = `function f(globalThis: { [k: string]: { fetch: (u: string) => void } }) {\n const g = globalThis['global' + 'This'];\n g.fetch('x');\n}`; + const shadowLiteral = `function f(globalThis: { globalThis: { fetch: (u: string) => void } }) {\n const g = globalThis.globalThis;\n g.fetch('x');\n}`; + expect(usesRuntimeCodeGeneration(shadowConcat)).toBe(usesRuntimeCodeGeneration(shadowLiteral)); + expect(usesRuntimeCodeGeneration(shadowConcat)).toBe(true); + }); + + // PRESERVE — the DIRECT shadowed self-hop MEMBER stays allowed: NET is checker-based and + // shadow-aware for direct member access, and RC does not fire on a direct member receiver. + it('preserves a direct shadowed self-hop member access', () => { + const src = `function f(globalThis: { globalThis: { fetch: (u: string) => void } }) {\n globalThis.globalThis.fetch('x');\n}`; + expect(usesOutboundNetwork(src)).toBe(false); + expect(usesRuntimeCodeGeneration(src)).toBe(false); + }); + + // PRESERVE — the frozen NET alias residual is untouched (NET still does not trace the alias; + // the source is denied by the RC forwarding closure, not by NET). + it('preserves the frozen NET alias residual (NET stays false)', () => { + expect(usesOutboundNetwork(`const g = globalThis.globalThis;\ng.fetch('https://example.com/');`)).toBe(false); + expect(usesOutboundNetwork(`const g = globalThis['global' + 'This'];\ng.fetch('https://example.com/');`)).toBe(false); + }); + + // PRESERVE — an unrelated local object whose base is NOT a global-receiver name is never folded + // into the global, whether accessed directly or forwarded. + it('preserves unrelated local objects with a globalThis property', () => { + expect(usesRuntimeCodeGeneration(`const o = { globalThis: { fetch(u: string) { return u; } } };\nconst g = o.globalThis;\ng.fetch('x');`)).toBe(false); + expect(usesOutboundNetwork(`const o = { globalThis: { fetch(u: string) { return u; } } };\no.globalThis.fetch('x');`)).toBe(false); + }); + + // PRESERVE — forwarding a benign statically-resolved global member (folded key is not a + // global-receiver name) is not a self-hop and stays allowed. + it('preserves forwarding of a benign resolved global member', () => { + expect(usesRuntimeCodeGeneration(`const c = globalThis['cons' + 'ole'];\nvoid c;`)).toBe(false); + expect(usesRuntimeCodeGeneration(`const c = globalThis['console'];\nvoid c;`)).toBe(false); + }); + + // PRESERVE — the DIRECT static-key self-hop member is still denied by NET's binder-aware path. + it('keeps the direct static-key self-hop member denied by NET', () => { + expect(usesOutboundNetwork(`globalThis['global' + 'This'].fetch('https://example.com/');`)).toBe(true); + expect(usesOutboundNetwork(`globalThis.globalThis.fetch('https://example.com/');`)).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // F1/F2 — the shared static-const resolver (collectStringConsts) must be // scope-sensitive and total. These suites drive the resolver THROUGH the RC/HA From e40e8c9d81d0a426adac1c366a17cdd1d3f47d82 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 1 Sep 2026 02:19:03 +0200 Subject: [PATCH 28/35] fix(cockpit): close createServer constructor injection Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fj88FKnrXXioWzdngyDcSG --- tests/cockpit-host/purity.test.ts | 117 ++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 796fab0..f3e78e3 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1363,6 +1363,16 @@ const STATIC_SOCKET_ACQUISITION_NAMES: ReadonlySet = new Set([ ...SOCKET_CAPABILITY_NAMES, ...SOCKET_DELIVERY_MEMBERS, ]); +// RULE A3 — the finite Node http.Server CONSTRUCTOR-INJECTION option family. `createServer`'s options +// object may name a custom request/response constructor class; Node then constructs the supplied +// `IncomingMessage` subclass with the LIVE connection socket (`constructor(socket)`) and the supplied +// `ServerResponse` subclass with the request object. A plain class passed under either key therefore +// receives that capability in its constructor without ever naming a banned socket property or a +// node:http value — a socket/request-delivery entry point the createServer allowance is not meant to +// grant. This is a closed two-name family on the installed Node 24 API (`ServerOptions.IncomingMessage` +// / `ServerOptions.ServerResponse`); reserving the option KEYS at the createServer acquisition site +// closes it with no class-body, constructor, or alias inspection. +const CONSTRUCTOR_INJECTION_OPTIONS: ReadonlySet = new Set(['IncomingMessage', 'ServerResponse']); // The static key named by an element-access argument or a binding-element key, or null. const staticKeyText = (key: ts.Node | undefined): string | null => { @@ -1459,6 +1469,37 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } }; const visit = (node: ts.Node): void => { + // RULE A3 — CONSTRUCTOR-INJECTION options on a permitted createServer call. Node constructs a + // supplied `IncomingMessage` subclass with the LIVE connection socket and a supplied + // `ServerResponse` subclass with the request, so an option naming either is a socket/request + // capability delivery. Only an OBJECT-LITERAL argument is inspected — the requestListener + // FUNCTION argument is skipped, and the check runs ONLY when `isCreateServerCall` already + // proved this is the node:http createServer capability, so an unrelated `foo.createServer({ + // IncomingMessage })` and any object literal OUTSIDE a createServer call are untouched. A + // property KEY is resolved by the SAME `staticKeyText`/`sockResolveKey` machinery as every + // other SOCK key (plain/quoted key is its own text; a computed key folds off the binder); a + // RESOLVED `IncomingMessage`/`ServerResponse` name REJECTS at this acquisition site — the + // supplied class value, its body, and its constructor are NEVER inspected (no taint/type/ + // whole-program, no alias following). A runtime/indeterminate key is no match here (unchanged + // SOCK disposition, not globally failed closed), and the exact-set test never matches a + // superstring key (`IncomingMessageLimit`). + if (isCreateServerCall(node)) { + for (const arg of (node as ts.CallExpression).arguments) { + const optionsObject = binderUnwrap(arg); + if (!ts.isObjectLiteralExpression(optionsObject)) continue; + for (const prop of optionsObject.properties) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue; + let name: string | null; + if (ts.isPropertyAssignment(prop) && ts.isComputedPropertyName(prop.name)) { + const key = sockResolveKey(prop.name.expression, checker, sockMemo); + name = key.kind === 'resolved' ? key.value : null; + } else { + name = staticKeyText(prop.name); + } + if (name !== null && CONSTRUCTOR_INJECTION_OPTIONS.has(name)) found = true; + } + } + } // RULE A (a) — GLOBAL dotted socket-acquisition NAME: `.socket`/`.connection` or a delivery // member `.on`/`.once`/`.addListener`/`.prependListener`/`.prependOnceListener`/ // `.setTimeout` (optional chaining included), any receiver, any position. This is the node @@ -6096,6 +6137,82 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(false); }); } + + // --- RULE A3 (createServer CONSTRUCTOR-INJECTION options): Node v24.12.0 constructs a supplied + // `IncomingMessage` subclass with the LIVE connection socket (`constructor(socket)`) and a + // supplied `ServerResponse` subclass with the request object. A PLAIN class passed as either + // option (`{ IncomingMessage: Capture } as any`) therefore receives the live socket in its + // constructor and can `destroy()`/`connect()` outbound WITHOUT naming any banned socket + // property or node:http value — the createServer options object is a socket/request-capability + // delivery the createServer allowance is not meant to grant. This surface is reachable ONLY + // through the allowed `http.createServer(options, …)` path (a `new http.Server({…})` form is + // already rejected as a non-createServer namespace member; a class that `extends + // http.IncomingMessage` is already rejected because that heritage reference is a non-createServer + // node:http VALUE). The option KEYS `IncomingMessage`/`ServerResponse` are reserved at the + // createServer acquisition SITE: the supplied class body, its constructor, and any alias are + // never inspected (no taint / type / whole-program / value-flow). Only an OBJECT-LITERAL + // argument is scanned (the requestListener function argument is skipped), and only when the call + // is already the permitted node:http createServer capability. The key is resolved by the SAME + // bounded `sockResolveKey`/`staticKeyText` machinery as every other SOCK key. MUST REJECT. --- + const injectionReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'A: the plain-class { IncomingMessage: Capture } custom-constructor socket delivery', source: H + `class Capture {\n constructor(socket: any) {\n socket.destroy();\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ IncomingMessage: Capture } as any, () => {});` }, + { form: "B: a static quoted { 'IncomingMessage': Capture } key", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ 'IncomingMessage': Capture } as any, () => {});` }, + { form: "C: a computed { ['Incoming' + 'Message']: Capture } key", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ ['Incoming' + 'Message']: Capture } as any, () => {});` }, + { form: "D: a const-bound { [k]: Capture } key with const k = 'IncomingMessage'", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nconst k = 'IncomingMessage';\nhttp.createServer({ [k]: Capture } as any, () => {});` }, + { form: 'E: a shorthand { IncomingMessage } option (plain class named IncomingMessage)', source: H + `class IncomingMessage {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ IncomingMessage } as any, () => {});` }, + { form: 'F: a { ServerResponse: Cap } custom-constructor option (parity)', source: H + `class Cap {\n constructor(req: any) {\n req.socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ ServerResponse: Cap } as any, () => {});` }, + { form: "G: a computed { ['Server' + 'Response']: Cap } key (parity)", source: H + `class Cap {\n constructor(req: any) {\n void req;\n }\n}\nhttp.createServer({ ['Server' + 'Response']: Cap } as any, () => {});` }, + { form: 'H: the options-first single-argument createServer({ IncomingMessage: Capture }) overload', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ IncomingMessage: Capture } as any);` }, + { form: 'I: a substituted-template { [`Incoming${\'Message\'}`]: Capture } key', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ [\`Incoming\${'Message'}\`]: Capture } as any, () => {});` }, + { form: 'J: both options together { IncomingMessage: A, ServerResponse: B }', source: H + `class A {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nclass B {}\nhttp.createServer({ IncomingMessage: A, ServerResponse: B } as any, () => {});` }, + ]; + for (const { form, source } of injectionReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- RULE A3 (preserve): the reservation fires ONLY for the finite injection option family + // (`IncomingMessage`/`ServerResponse`) on a PROVEN node:http createServer call. Benign + // createServer options, an empty/handler-only call, an unrelated `.createServer` that is not the + // node:http capability, and any object literal carrying an `IncomingMessage`/`ServerResponse` key + // OUTSIDE a createServer call are all untouched — no object literal is globally banned, and a key + // that merely CONTAINS the reserved name (an exact-set membership test, never a substring) is not + // matched. Runtime/indeterminate option keys follow the existing SOCK disposition (no match here, + // not globally failed closed). MUST ALLOW. --- + const injectionAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a benign { maxHeaderSize: 8192 } options object', source: H + `http.createServer({ maxHeaderSize: 8192 }, () => {});` }, + { form: 'a benign { keepAlive: true, keepAliveTimeout: 5000 } options object', source: H + `http.createServer({ keepAlive: true, keepAliveTimeout: 5000 }, () => {});` }, + { form: 'the handler-only createServer(handler) form', source: H + `http.createServer(() => {});` }, + { form: 'an unrelated foo.createServer({ IncomingMessage: X }) that is not the node:http capability', source: `const foo = { createServer(_o: unknown, _h: () => void): void {} };\nclass X {}\nfoo.createServer({ IncomingMessage: X }, () => {});` }, + { form: 'an object literal with an IncomingMessage key OUTSIDE any createServer call', source: H + `class X {}\nconst options = { IncomingMessage: X };\nvoid options;\nhttp.createServer(() => {});` }, + { form: 'a benign superstring key { IncomingMessageLimit: 10 } (exact-set, not substring)', source: H + `http.createServer({ IncomingMessageLimit: 10 } as any, () => {});` }, + { form: 'a genuinely runtime option key { [runtimeKey]: X } stays outside the proof', source: H + `declare const runtimeKey: string;\nclass X {}\nhttp.createServer({ [runtimeKey]: X } as any, () => {});` }, + ]; + for (const { form, source } of injectionAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // --- RULE A3 (prompt-supplied exact witness, defense-in-depth): the extends-form witness the + // cumulative audit reproduced (`class Capture extends http.IncomingMessage { super(socket) }`) + // is ALSO rejected — here already by NET's positive node:http model (the `http.IncomingMessage` + // heritage reference is a non-createServer node:http value), independently of RULE A3. Asserted + // so the exact reported spelling is pinned closed. MUST REJECT. --- + it('rejects the prompt-supplied extends-form IncomingMessage witness (already closed by NET)', () => { + const source = + H + + `class Capture extends http.IncomingMessage {\n` + + ` constructor(socket: any) {\n` + + ` super(socket);\n` + + ` socket.destroy();\n` + + ` setTimeout(() => socket.connect(80, 'example.com'), 50);\n` + + ` }\n` + + `}\n` + + `http.createServer({ IncomingMessage: Capture } as any, () => {});`; + expect(usesOutboundNetwork(source)).toBe(true); + }); }); // --------------------------------------------------------------------------- From 430f11d37068eb1a1c32dea6164a71e16779f8f1 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 1 Sep 2026 08:01:50 +0200 Subject: [PATCH 29/35] fix(cockpit): resolve bounded createServer arguments Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CdLcbXZMib9vDjZ7Lgr8gy --- tests/cockpit-host/purity.test.ts | 140 +++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index f3e78e3..6b68e8b 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1400,15 +1400,58 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // stay local (cycle detection + the per-key hop ceiling). const sockMemo = new Map(); + // ROOT-FAMILY-CS-ARGUMENT-RESOLUTION — normalize ONE argument of an already-proven node:http + // createServer call to the finite provable source expression the positive policy must inspect, + // BEFORE Pass 1's handler-parameter collection and RULE A3's option scan look at it. Only unique, + // immutable, binder-proven LOCAL bindings are followed: a transparent wrapper (`binderUnwrap`: + // paren / `as` / satisfies / `!` / type-assertion / await), a unique `const` identifier folded to + // its initializer (the SAME `netUniqueConstDecl` primitive the NET/SOCK key resolvers use — + // binder identity, exactly one declaration, `const` list flag — iterated so a `const b = a; const + // a = {…}` alias spine resolves), and a unique `FunctionDeclaration` identifier folded to its + // declaration node. Resolution STOPS (returns the last node reached, i.e. Indeterminate for the + // caller's type guards) on every mutable / runtime / unsupported form — a `let`/`var` binding + // (identity can change), a parameter, a property, a call result, a spread-built object, a + // duplicate/absent declaration, an alias cycle — so those stay outside the proof exactly as the + // frozen positive model already leaves `createServer(getOptions())` outside it. An explicit hop + // cap plus a visited-declaration set guarantee termination. This is bounded local declaration + // NORMALIZATION scoped to createServer arguments — NOT value flow, alias/taint propagation, a call + // graph, or class-body inspection: the resolved node's VALUE, body, and constructor are never + // read here; the caller's existing guards (`isArrowFunction`/`isFunctionExpression`/ + // `isFunctionDeclaration` for the handler, `isObjectLiteralExpression` for the options) decide + // whether the finite policy applies, and the option KEYS are still resolved by the same bounded + // `staticKeyText`/`sockResolveKey` machinery. A DIRECT inline function / object-literal argument + // is returned unchanged (the loop never runs), so existing direct-form behavior is identical. + const CS_ARG_RESOLVE_HOP_CAP = 64; + const resolveCreateServerArgument = (arg: ts.Expression): ts.Node => { + let cur: ts.Expression = binderUnwrap(arg); + const seen = new Set(); + for (let hops = 0; ts.isIdentifier(cur) && hops < CS_ARG_RESOLVE_HOP_CAP; hops++) { + const symbol = checker.getSymbolAtLocation(cur); + const decls = symbol?.declarations; + if (decls === undefined || decls.length !== 1) return cur; // ambient / duplicate / undeclared: stop + const decl = decls[0]; + if (decl === undefined || seen.has(decl)) return cur; // alias cycle / re-entry: stop in finite time + seen.add(decl); + if (ts.isFunctionDeclaration(decl)) return decl; // unique named-function handler: the function IS the source + const constDecl = netUniqueConstDecl(cur, checker); + if (constDecl === null || constDecl.initializer === undefined) return cur; // let/var/param/property/no-init: stop + cur = binderUnwrap(constDecl.initializer); // unique `const` hop (const→const spine included) + } + return cur; + }; + // Pass 1 (RULE A2 support) — collect the createServer request/response parameter symbols. // Direct identifier params only; a destructured param `({ socket })` is a RULE A binding - // pattern, rejected in pass 2 like any other. + // pattern, rejected in pass 2 like any other. The listener argument is first normalized through + // the bounded createServer argument resolver, so a unique-`const`/`FunctionDeclaration`-bound + // named handler (`function handler(req){…}; createServer(handler)`) contributes its parameters + // exactly as a direct inline arrow/function expression does (ROOT-FAMILY-CS-ARGUMENT-RESOLUTION F1). const reqResSymbols = new Set(); const collect = (node: ts.Node): void => { if (isCreateServerCall(node)) { for (const arg of (node as ts.CallExpression).arguments) { - const handler = binderUnwrap(arg); - if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) { + const handler = resolveCreateServerArgument(arg); + if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler) || ts.isFunctionDeclaration(handler)) { for (const param of handler.parameters) { if (ts.isIdentifier(param.name)) { const symbol = checker.getSymbolAtLocation(param.name); @@ -1485,7 +1528,13 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // superstring key (`IncomingMessageLimit`). if (isCreateServerCall(node)) { for (const arg of (node as ts.CallExpression).arguments) { - const optionsObject = binderUnwrap(arg); + // ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F2): normalize the argument through the bounded + // createServer argument resolver first, so a unique-`const`-bound alias of the options + // object (`const options = { IncomingMessage: Capture }; createServer(options as any, …)`, + // including a computed-static option key and a const→const spine) is reserved exactly like + // a direct object literal. A `let`/parameter/call-result/spread options argument does not + // resolve to an ObjectLiteralExpression and stays outside the proof (frozen positive model). + const optionsObject = resolveCreateServerArgument(arg); if (!ts.isObjectLiteralExpression(optionsObject)) continue; for (const prop of optionsObject.properties) { if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue; @@ -6213,6 +6262,89 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 `http.createServer({ IncomingMessage: Capture } as any, () => {});`; expect(usesOutboundNetwork(source)).toBe(true); }); + + // --- ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F1 — named/const-bound request listeners): a createServer + // request listener supplied by a unique immutable local binding (a `FunctionDeclaration`, a + // `const` arrow, or a `const` function expression) now contributes its request/response + // parameters to the RULE A2 fail-closed set exactly as a DIRECT inline arrow/function does. The + // discriminating witness is an INDETERMINATE computed member access on the request parameter + // (`const s = req[runtimeKey]; s.connect(80, …)`): `.connect`/`.destroy` are NOT globally banned + // socket-acquisition names (only `.socket`/`.connection`/the delivery members are), so this + // socket recovery is caught ONLY by RULE A2's req/res-bound indeterminate-key fail-close — which + // needs the handler parameter symbol. Before the argument-resolution repair a named/const handler + // never registered its parameter and the access escaped; now it is reserved. MUST REJECT. --- + const RK = `declare const rk: string;\n`; + const namedHandlerReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'A: a FunctionDeclaration handler (req[runtimeKey] socket recovery)', source: H + RK + `function handler(req: any, res: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n}\nhttp.createServer(handler);` }, + { form: 'B: a const-arrow handler (req[runtimeKey] socket recovery)', source: H + RK + `const handler = (req: any, res: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'C: a const-function-expression handler (req[runtimeKey] socket recovery)', source: H + RK + `const handler = function (req: any, res: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'D: an as-wrapped const-arrow handler', source: H + RK + `const handler = (req: any, res: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler as any);` }, + { form: 'E: a const-chain alias to a FunctionDeclaration handler', source: H + RK + `function base(req: any, res: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n}\nconst handler = base;\nhttp.createServer(handler);` }, + { form: 'F: a const-arrow handler with an indeterminate DESTRUCTURING key off req', source: H + RK + `const handler = (req: any, res: any) => {\n const { [rk]: s } = req;\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + ]; + for (const { form, source } of namedHandlerReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F1 — preserve): the resolver follows ONLY unique immutable + // bindings, so a benign named/const handler stays allowed, the inline baseline is unchanged, and + // a MUTABLE/runtime listener whose identity the analyzer cannot pin (`let` handler, a + // parameter-supplied handler) stays OUTSIDE the proof — the frozen positive model denies only + // what it can prove, exactly as it already allows `createServer(getListener())`. MUST ALLOW. --- + const namedHandlerAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a benign FunctionDeclaration handler (no socket capability)', source: H + `function handler(req: any, res: any) {\n res.end('ok');\n}\nhttp.createServer(handler);` }, + { form: 'a benign const-arrow handler (static harmless key)', source: H + `const handler = (req: any, res: any) => {\n const m = req['method'];\n void m;\n};\nhttp.createServer(handler);` }, + { form: 'the inline-arrow baseline with a benign body (unchanged)', source: H + `http.createServer((req: any, res: any) => {\n res.end('ok');\n});` }, + { form: 'a MUTABLE let handler with req[runtimeKey] stays outside the proof', source: H + RK + `let handler = (req: any, res: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'a parameter-supplied handler stays outside the proof', source: H + RK + `function mount(handler: any) {\n http.createServer(handler);\n}\nvoid mount;` }, + ]; + for (const { form, source } of namedHandlerAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // --- ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F2 — aliased constructor-injection options): a createServer + // options object supplied by a unique immutable local binding is normalized to its object literal + // and its KEYS reserved exactly like a direct `{ IncomingMessage: … }` literal — a plain const + // alias, a const alias with a computed-static / const-key option key, a `ServerResponse` alias, + // an as-wrapped alias, and a const→const spine. The supplied class value/body/constructor are + // still never inspected; only the option KEY is read. MUST REJECT. --- + const aliasedOptionsReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a: a plain const options alias { IncomingMessage: Capture }', source: H + `class Capture {}\nconst options = { IncomingMessage: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: "b: a const options alias with a computed key { ['Incoming'+'Message']: Capture }", source: H + `class Capture {}\nconst options = { ['Incoming' + 'Message']: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: "c: a const options alias with a const-bound computed key", source: H + `class Capture {}\nconst key = 'IncomingMessage';\nconst options = { [key]: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: 'd: a const ServerResponse options alias (parity)', source: H + `class Cap {}\nconst options = { ServerResponse: Cap };\nhttp.createServer(options as any, () => {});` }, + { form: 'e: an as-wrapped const options alias', source: H + `class Capture {}\nconst options = { IncomingMessage: Capture };\nhttp.createServer((options as any), () => {});` }, + { form: 'f: a const->const options spine', source: H + `class Capture {}\nconst a = { IncomingMessage: Capture };\nconst options = a;\nhttp.createServer(options as any, () => {});` }, + ]; + for (const { form, source } of aliasedOptionsReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F2 — preserve): a benign const options alias stays allowed, + // a superstring key is still an exact-set miss, and a MUTABLE/runtime options argument + // (`let` options, a parameter, a call result, a spread-built object) stays OUTSIDE the proof — + // the same frozen positive-model disposition that already allows `createServer(getOptions())`. + // An object literal carrying the key OUTSIDE any createServer call is still untouched. MUST ALLOW. + const aliasedOptionsAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a benign const options alias { maxHeaderSize: 8192 }', source: H + `const options = { maxHeaderSize: 8192 };\nhttp.createServer(options, () => {});` }, + { form: 'a benign const alias superstring key { IncomingMessageLimit: 10 }', source: H + `const options = { IncomingMessageLimit: 10 };\nhttp.createServer(options as any, () => {});` }, + { form: 'a MUTABLE let options alias stays outside the proof', source: H + `class Capture {}\nlet options = { IncomingMessage: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: 'a parameter-supplied options object stays outside the proof', source: H + `function mount(options: any) {\n http.createServer(options as any, () => {});\n}\nvoid mount;` }, + { form: 'a call-result options object stays outside the proof', source: H + `declare function getOptions(): any;\nhttp.createServer(getOptions(), () => {});` }, + { form: 'a spread-copied const options object stays outside the proof (spread not value-followed)', source: H + `class Capture {}\nconst base = { IncomingMessage: Capture };\nconst options = { ...base };\nhttp.createServer(options as any, () => {});` }, + { form: 'a const options object with the key OUTSIDE any createServer call', source: H + `class X {}\nconst options = { IncomingMessage: X };\nvoid options;\nhttp.createServer(() => {});` }, + ]; + for (const { form, source } of aliasedOptionsAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } }); // --------------------------------------------------------------------------- From 345b9e34a26fb397a1d5f8060901d71949e75612 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 1 Sep 2026 09:11:49 +0200 Subject: [PATCH 30/35] fix(cockpit): inspect constructor option getters Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0168Ff8rrLX6K8yWx8WhwrUY --- tests/cockpit-host/purity.test.ts | 67 ++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 6b68e8b..be01a29 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1537,9 +1537,22 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou const optionsObject = resolveCreateServerArgument(arg); if (!ts.isObjectLiteralExpression(optionsObject)) continue; for (const prop of optionsObject.properties) { - if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue; + // PROPERTY-KIND PARITY (Codex P1): reserve the constructor-option KEY for every element whose + // property READ delivers the caller's value to Node — a data property (PropertyAssignment / + // ShorthandPropertyAssignment) OR a GETTER (GetAccessorDeclaration: Node reads the option + // with an ordinary property GET, which RUNS the getter, then constructs its return with the + // live connection socket). A SET-only accessor (property read is `undefined`, so Node falls + // back to its own default), an object-literal concise MethodDeclaration (a concise method is + // NOT constructable — `new` throws), and a SpreadAssignment (outside the frozen value-flow + // boundary) deliver no usable constructor and are skipped. The element VALUE/body is never + // inspected; only the KEY is resolved, by the same staticKeyText/sockResolveKey machinery. + if ( + !ts.isPropertyAssignment(prop) && + !ts.isShorthandPropertyAssignment(prop) && + !ts.isGetAccessorDeclaration(prop) + ) continue; let name: string | null; - if (ts.isPropertyAssignment(prop) && ts.isComputedPropertyName(prop.name)) { + if (!ts.isShorthandPropertyAssignment(prop) && ts.isComputedPropertyName(prop.name)) { const key = sockResolveKey(prop.name.expression, checker, sockMemo); name = key.kind === 'resolved' ? key.value : null; } else { @@ -6263,6 +6276,56 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(true); }); + // --- RULE A3 PROPERTY-KIND PARITY (Codex P1 — constructor-option ACCESSORS): the RULE A3 + // constructor-option reservation classifies an options-object element by its PROPERTY KIND, never + // by its value. Node v24.12.0 reads `options.IncomingMessage` / `.ServerResponse` with an ordinary + // property GET, so any element whose READ yields the caller's value delivers the socket/request + // constructor: a data property (PropertyAssignment / shorthand) AND a GETTER — reading the option + // RUNS the getter, and Node then constructs its return with the live connection socket (verified + // constructable under the actual runtime). A getter therefore has exact parity with the already + // reserved data-property form and MUST REJECT: plain, quoted, computed-static, and const-key + // getters, for both `IncomingMessage` and `ServerResponse`, and through the bounded createServer + // argument resolver (const-alias / options-first overload). The getter BODY and return value are + // never inspected — only the KEY is resolved, by the same `staticKeyText`/`sockResolveKey` + // machinery as every other constructor-option key. MUST REJECT. --- + const accessorInjectionReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a: a getter { get IncomingMessage() { return Capture; } } (socket-capability constructor)', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ get IncomingMessage() { return Capture; } } as any, () => {});` }, + { form: "b: a quoted getter { get 'IncomingMessage'() { … } }", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ get 'IncomingMessage'() { return Capture; } } as any, () => {});` }, + { form: "c: a computed-static getter { get ['Incoming' + 'Message']() { … } }", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ get ['Incoming' + 'Message']() { return Capture; } } as any, () => {});` }, + { form: "d: a const-key getter { get [k]() { … } } with const k = 'IncomingMessage'", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nconst k = 'IncomingMessage';\nhttp.createServer({ get [k]() { return Capture; } } as any, () => {});` }, + { form: 'e: a getter { get ServerResponse() { … } } (request-capability parity, A3-key isolated)', source: H + `class Cap {\n constructor(req: any) {\n void req;\n }\n}\nhttp.createServer({ get ServerResponse() { return Cap; } } as any, () => {});` }, + { form: "f: a computed-static getter { get ['Server' + 'Response']() { … } }", source: H + `class Cap {\n constructor(req: any) {\n void req;\n }\n}\nhttp.createServer({ get ['Server' + 'Response']() { return Cap; } } as any, () => {});` }, + { form: 'g: a getter reached through a const options alias (bounded arg resolver + A3)', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nconst options = { get IncomingMessage() { return Capture; } };\nhttp.createServer(options as any, () => {});` }, + { form: 'h: a getter on the options-first single-argument overload', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ get IncomingMessage() { return Capture; } } as any);` }, + ]; + for (const { form, source } of accessorInjectionReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- RULE A3 PROPERTY-KIND PARITY (preserve — non-delivering element kinds stay OUTSIDE the + // reservation, justified by Node read semantics rather than guessed): a SET-only accessor reads as + // `undefined` (Node then falls back to its OWN default IncomingMessage/ServerResponse — no caller + // value is delivered); an object-literal CONCISE METHOD reads as a function that is NOT a + // constructor (`new` throws under the actual runtime), so it cannot receive the socket either; and + // a SPREAD element remains outside the frozen value-flow boundary (unchanged). A benign + // non-reserved accessor, a superstring-key getter (exact-set miss, never a substring), and a + // reserved-key getter OUTSIDE any createServer call are likewise untouched. MUST ALLOW. --- + const accessorInjectionAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a SET-only accessor { set IncomingMessage(v) {} } (property read is undefined → default)', source: H + `http.createServer({ set IncomingMessage(_v: any) {} } as any, () => {});` }, + { form: 'a concise METHOD { IncomingMessage() {} } (a concise method is not constructable)', source: H + `http.createServer({ IncomingMessage() {} } as any, () => {});` }, + { form: 'a SPREAD element { ...base } carrying a getter stays outside the value-flow boundary', source: H + `class Capture {}\nconst base = { get IncomingMessage() { return Capture; } };\nhttp.createServer({ ...base } as any, () => {});` }, + { form: 'a benign non-reserved getter { get maxHeaderSize() { return 8192; } }', source: H + `http.createServer({ get maxHeaderSize() { return 8192; } } as any, () => {});` }, + { form: 'a superstring-key getter { get IncomingMessageLimit() { … } } (exact-set miss)', source: H + `http.createServer({ get IncomingMessageLimit() { return 10; } } as any, () => {});` }, + { form: 'a reserved-key getter OUTSIDE any createServer call', source: H + `class X {}\nconst options = { get IncomingMessage() { return X; } };\nvoid options;\nhttp.createServer(() => {});` }, + ]; + for (const { form, source } of accessorInjectionAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + // --- ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F1 — named/const-bound request listeners): a createServer // request listener supplied by a unique immutable local binding (a `FunctionDeclaration`, a // `const` arrow, or a `const` function expression) now contributes its request/response From 4569d6e37c926c9762f2b47d3fc5ce542e486200 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 2 Sep 2026 08:26:45 +0200 Subject: [PATCH 31/35] fix(cockpit): close bounded network purity gaps Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0121zTdfx8cyXQCQLJtN6Uzq --- tests/cockpit-host/purity.test.ts | 1300 ++++++++++++++++++++++++++--- 1 file changed, 1191 insertions(+), 109 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index be01a29..c8cca15 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1343,7 +1343,10 @@ const isDynamicNodeHttpImport = (node: ts.Node): boolean => { // not this source policy. // The SOCKET-VALUE names (RULE A family i) — the duplex socket itself. Still used verbatim by // the RULE A2 req/res-bound branches below, whose socket/connection semantics are preserved. -const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connection']); +// MECH-2 (Day-7 convergence): `client` is IncomingMessage's third alias of the SAME duplex socket +// (`req.client === req.socket` on the installed Node 24 API), so it joins family (i) — one inventory, +// receiver-independent like `socket`/`connection`, with no parallel name list. +const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connection', 'client']); // The SOCKET-DELIVERY member names (RULE A family ii) — the permitted http.Server callbacks // that hand a socket to a handler. `setTimeout` (the one-shot 'timeout' socket — F1) sits // beside the five event registrars; the whole family is banned by NAME at any position (F2), @@ -1357,11 +1360,62 @@ const SOCKET_DELIVERY_MEMBERS: ReadonlySet = new Set([ 'prependOnceListener', 'setTimeout', ]); -// The full RULE A static name family (i ∪ ii), tested at every statically identifiable +// RULE A family (iii) — CONSTRUCTOR RE-DERIVATION (Day-7 F2). The permitted createServer CALL RESULT +// is an http.Server whose ordinary `constructor` property (inherited from `http.Server.prototype`) IS +// the privileged Server constructor: `new (http.createServer() as any).constructor(listener)` builds a +// second server whose listener/options never pass through the createServer argument boundary, and +// `Object.getPrototypeOf(server.constructor)` reaches `net.Server`. The name joins the SAME +// receiver-independent static-name ban as the delivery members — dotted, static/binder-resolved element, +// declaration destructuring, ASSIGNMENT destructuring, and the structural reflective read — so every +// statically spelled `constructor` acquisition is rejected where the name appears, with no server +// identity tracked and no constructor-specific dataflow. A ConstructorDeclaration (`class X { +// constructor() {} }`), an object-literal DATA key `{ constructor: 1 }`, the string `'constructor'`, and a +// superstring (`constructorName`) are not this name at a member/binding/assignment-key position and are +// untouched. (RC rule (a) already bans the dotted/static spelling by TEXT for code generation; this +// entry gives NET the binder-identity, destructuring, assignment, and reflective parity RC lacks.) +const CONSTRUCTOR_REDERIVATION_NAMES: ReadonlySet = new Set(['constructor']); +// RULE A family (iv) — PROTOTYPE REACH (Day-7 MECH-1 prototype-inheritance closure). A supported +// createServer options literal (inline, or reached through a confined const spine) inherits from +// `Object.prototype`, and Node reads `options.IncomingMessage` / `options.ServerResponse` with an +// ordinary GET that walks the prototype chain — so polluting `Object.prototype` (`Object.prototype.X =`, +// `Object.assign/defineProperty/defineProperties(Object.prototype, …)`, `Reflect.set(Object.prototype, …)`, +// an inherited GETTER) makes an otherwise clean `{}` deliver the live socket (verified on Node 24; the +// no-options form is immune because Node substitutes a frozen null-prototype object). No static rule can +// prove `Object.prototype` pristine, and a boundary-local check (only in files that pass options) is +// UNSOUND across the scanned host tree — the pollution site and the options literal may sit in different +// host files. The closure is therefore the SAME receiver-independent static-name reservation the other +// RULE A families use: every statically spelled path to an object's prototype names `prototype` +// (`Object.prototype`, `Object['proto' + 'type']`, `const { prototype } = Object`), `__proto__` +// (`({}).__proto__`, `const { __proto__: p } = x`, `x.__proto__ = …`), or `getPrototypeOf` +// (`Object.getPrototypeOf({})`, `Reflect.getPrototypeOf(…)`, `const { getPrototypeOf } = Object`), and +// each is rejected where the NAME appears — dotted, binder-resolved element, declaration destructuring, +// assignment destructuring, and the structural reflective read (`Reflect.get(Object, 'prototype')`, +// `Object.getOwnPropertyDescriptor(Object, 'prototype')`). `setPrototypeOf` is NOT needed here: +// `Object.prototype` is an immutable-prototype exotic object (`Object.setPrototypeOf` throws, +// `Reflect.setPrototypeOf` returns false), and re-prototyping the options object itself is already +// rejected by the argument-shape / const-spine confinement rules. No prototype graph is traversed and no +// receiver is tracked. The object-literal `__proto__:` DATA key stays a separate options-literal rule +// (it sets the literal's own prototype); an object-literal data key `{ prototype: 1 }`, the strings +// `'prototype'` / `'__proto__'`, a superstring (`prototypeName`), a class declaration, and a type-position +// member are not this name at a member / binding / assignment-key position and are untouched. The real +// host names none of these (it is an accepted RULE A policy false positive elsewhere, like `camera.socket`). +const PROTOTYPE_REACH_NAMES: ReadonlySet = new Set(['prototype', '__proto__', 'getPrototypeOf']); +// The receiver-INDEPENDENT ASSIGNMENT-destructuring family: the delivery/registrar members (family ii), +// constructor re-derivation (family iii), and prototype reach (family iv). The socket-value names (family i) +// are deliberately NOT here — their assignment extraction stays req/res-bound (see +// `scanSocketAssignmentTarget`). +const RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES: ReadonlySet = new Set([ + ...SOCKET_DELIVERY_MEMBERS, + ...CONSTRUCTOR_REDERIVATION_NAMES, + ...PROTOTYPE_REACH_NAMES, +]); +// The full RULE A static name family (i ∪ ii ∪ iii ∪ iv), tested at every statically identifiable // property/binding-key position (dotted, static-computed, destructured), receiver-independent. const STATIC_SOCKET_ACQUISITION_NAMES: ReadonlySet = new Set([ ...SOCKET_CAPABILITY_NAMES, ...SOCKET_DELIVERY_MEMBERS, + ...CONSTRUCTOR_REDERIVATION_NAMES, + ...PROTOTYPE_REACH_NAMES, ]); // RULE A3 — the finite Node http.Server CONSTRUCTOR-INJECTION option family. `createServer`'s options // object may name a custom request/response constructor class; Node then constructs the supplied @@ -1384,8 +1438,14 @@ const staticKeyText = (key: ts.Node | undefined): string | null => { }; const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { - const isCreateServerCall = (node: ts.Node): boolean => - ts.isCallExpression(node) && classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; + // MECH-1 (Day-7 convergence): the privileged createServer BOUNDARY is a CallExpression OR a + // NewExpression whose callee is binder-proven CREATE_SERVER. `new http.createServer(…)` runs the + // same factory with the same arguments (and the constructor position is already a NET-safe + // position for the capability), so it is the SAME boundary — argument disposition is identical + // for both forms; `arguments ?? []` covers the argument-less `new http.createServer` spelling. + const isCreateServerCall = (node: ts.Node): node is ts.CallExpression | ts.NewExpression => + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; // DDR-NET-STATIC-KEY-PARITY (SOCK): socket acquisition keys are resolved by TypeScript BINDER // identity via the shared bounded `netResolveKey` (see `sockResolveKey`) — the SAME resolver NET's @@ -1422,7 +1482,10 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // `staticKeyText`/`sockResolveKey` machinery. A DIRECT inline function / object-literal argument // is returned unchanged (the loop never runs), so existing direct-form behavior is identical. const CS_ARG_RESOLVE_HOP_CAP = 64; - const resolveCreateServerArgument = (arg: ts.Expression): ts.Node => { + // `spine` (optional out-parameter) receives every unique-`const` declaration the resolution passed + // through, boundary-side first (`createServer(b)` with `const b = a; const a = {…}` yields [b, a]). + // The options-literal CONFINEMENT check below needs the spine bindings, not only the final node. + const resolveCreateServerArgument = (arg: ts.Expression, spine?: ts.VariableDeclaration[]): ts.Node => { let cur: ts.Expression = binderUnwrap(arg); const seen = new Set(); for (let hops = 0; ts.isIdentifier(cur) && hops < CS_ARG_RESOLVE_HOP_CAP; hops++) { @@ -1435,44 +1498,343 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou if (ts.isFunctionDeclaration(decl)) return decl; // unique named-function handler: the function IS the source const constDecl = netUniqueConstDecl(cur, checker); if (constDecl === null || constDecl.initializer === undefined) return cur; // let/var/param/property/no-init: stop + spine?.push(constDecl); cur = binderUnwrap(constDecl.initializer); // unique `const` hop (const→const spine included) } return cur; }; - // Pass 1 (RULE A2 support) — collect the createServer request/response parameter symbols. - // Direct identifier params only; a destructured param `({ socket })` is a RULE A binding - // pattern, rejected in pass 2 like any other. The listener argument is first normalized through - // the bounded createServer argument resolver, so a unique-`const`/`FunctionDeclaration`-bound - // named handler (`function handler(req){…}; createServer(handler)`) contributes its parameters - // exactly as a direct inline arrow/function expression does (ROOT-FAMILY-CS-ARGUMENT-RESOLUTION F1). + // Day-7 MECH-1 — CONST-SPINE CONFINEMENT of a resolved OPTIONS LITERAL. A `const` freezes the + // BINDING, not the OBJECT: `const o = {}; o.IncomingMessage = Capture; http.createServer(o, …)` + // resolves `o` to the literal `{}` while Node reads the mutated object at call time. Property / + // element / computed / const-key / runtime-key assignment, `Object.assign` / `defineProperty` / + // `setPrototypeOf` / `Reflect.set`, `o.__proto__ = …`, a mutation through an alias or a bounded alias + // chain, a mutation inside a function or closure, and a mutation textually AFTER the call (execution + // order is not statically provable) all deliver the live socket. Static object immutability cannot + // be proven inside this bounded mechanism, so the disposition is POSITIONAL and fail-closed, the same + // shape as `isCreateServerSafePosition` / `isHttpNsSafePosition`: the literal reached through a + // spine is a supported source ONLY IF every binding on the spine is referenced NOWHERE except + // (1) as the initializer of the previous spine binding (`const b = a` — the spine itself), and + // (2) as an argument of a binder-proven createServer call / `new` (through the transparent + // wrappers the resolver strips), + // and no spine binding is `export`ed (a live import binding elsewhere could mutate the object). + // ANY other occurrence — an assignment target receiver, a benign-looking read, a side alias + // (`const c = a`), a call argument, a container element, an object value (shorthand included), a + // return, an export specifier / default export, a closure capture, a type query — is an escape of + // the object's IDENTITY and DENIES at the boundary. Nothing is claimed about what the escape does: + // the object might be mutated, so it is not a supported source. References are inventoried ONCE + // per traversal by BINDER SYMBOL (a same-text binding in another scope is a different symbol and + // is irrelevant); a shorthand `{ o }` resolves through `getShorthandAssignmentValueSymbol` and an + // `export { o }` through `getExportSpecifierLocalTargetSymbol`; a same-text VALUE identifier the + // binder cannot resolve at all is treated as a reference (fail-closed). A function-like source is + // NOT subject to this rule: a function's code cannot be replaced through its object. Total and + // terminating: one file walk builds the inventory, each spine binding's references are visited + // once, each site is classified by a constant-depth parent walk; no value flow, no alias graph. + let referenceInventory: { readonly refs: ReadonlyMap; readonly unresolved: ReadonlySet } | null = null; + const inventoryValueReferences = (): NonNullable => { + if (referenceInventory !== null) return referenceInventory; + const refs = new Map(); + const unresolved = new Set(); + const record = (symbol: ts.Symbol | undefined, site: ts.Node, text: string): void => { + if (symbol === undefined) { + unresolved.add(text); + return; + } + const list = refs.get(symbol); + if (list === undefined) refs.set(symbol, [site]); + else list.push(site); + }; + const walk = (node: ts.Node): void => { + if (ts.isIdentifier(node) && isBinderValueReference(node)) { + const p = node.parent; + if (ts.isShorthandPropertyAssignment(p) && p.name === node) { + record(checker.getShorthandAssignmentValueSymbol(p), node, node.text); + } else { + record(checker.getSymbolAtLocation(node), node, node.text); + } + } else if (ts.isExportSpecifier(node)) { + record(checker.getExportSpecifierLocalTargetSymbol(node), node, (node.propertyName ?? node.name).text); + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(sourceFile, walk); + referenceInventory = { refs, unresolved }; + return referenceInventory; + }; + // Walk up through the transparent wrappers the resolver strips (paren / as / satisfies / `!` / + // type-assertion / await) and return the outermost wrapped node. + const outermostTransparentWrapper = (site: ts.Node): ts.Node => { + let cur: ts.Node = site; + for (;;) { + const p = cur.parent as ts.Node | undefined; + if ( + p !== undefined && + (ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p) || + ts.isTypeAssertionExpression(p) || + ts.isAwaitExpression(p)) && + p.expression === cur + ) { + cur = p; + continue; + } + return cur; + } + }; + const isCreateServerBoundaryArgument = (site: ts.Node): boolean => { + const cur = outermostTransparentWrapper(site); + const p = cur.parent as ts.Node | undefined; + return p !== undefined && isCreateServerCall(p) && (p.arguments ?? []).some((a) => a === cur); + }; + const isInitializerOf = (site: ts.Node, decl: ts.VariableDeclaration): boolean => { + const cur = outermostTransparentWrapper(site); + return cur.parent === decl && decl.initializer === cur; + }; + const optionsSpineIsConfined = (spine: readonly ts.VariableDeclaration[]): boolean => { + const { refs, unresolved } = inventoryValueReferences(); + for (let i = 0; i < spine.length; i++) { + const decl = spine[i]; + if (decl === undefined || !ts.isIdentifier(decl.name)) return false; + const statement = decl.parent.parent as ts.Node | undefined; + if ( + statement !== undefined && + ts.isVariableStatement(statement) && + ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true + ) { + return false; // an exported binding is a live (mutable-object) import elsewhere + } + if (unresolved.has(decl.name.text)) return false; // a same-text reference the binder could not pin + const symbol = checker.getSymbolAtLocation(decl.name); + if (symbol === undefined) return false; + const previous = i > 0 ? spine[i - 1] : undefined; + for (const site of refs.get(symbol) ?? []) { + if (isCreateServerBoundaryArgument(site)) continue; + if (previous !== undefined && isInitializerOf(site, previous)) continue; + return false; // any other occurrence: the object's identity escaped / may be mutated + } + } + return true; + }; + + let found = false; + + // RULE A2 (binding-pattern walk — MECH-2 object-rest + nested fail-close) — walk ONE finite binding + // pattern whose ROOT is a tracked req/res receiver (a createServer listener PARAMETER pattern, or + // the `= req/res` initializer of a DECLARATION pattern). Two things reject, at ANY nesting depth + // of the pattern: (a) an OBJECT-REST element (`{ ...rest }`) — the rest object carries every + // own enumerable property of the receiver, socket included, into an untracked binding, so the + // acquisition is rejected HERE and the rest object is never propagated/followed; (b) an + // INDETERMINATE computed key — the SAME predicate the top-level RULE A2 declaration branch has + // always used (`staticKeyText === null`), now applied at every depth so `{ req: { [k]: s } }` + // off `res` fails closed exactly like `res.req[k]`. Nesting is walked STRUCTURALLY through the + // finite binding AST only (object and array sub-patterns; an array hole is skipped; an ARRAY rest + // `[...r]` is not an object rest and is not rejected), terminating at the pattern leaves — no + // value flow, no alias following. A statically resolvable harmless key (`{ method }`) is + // unaffected; a socket-acquisition NAME is already rejected by RULE A (c). + const scanReqResBindingPattern = (pattern: ts.BindingPattern): void => { + for (const el of pattern.elements) { + if (!ts.isBindingElement(el)) continue; // an array-pattern hole (OmittedExpression) + if (ts.isObjectBindingPattern(pattern)) { + if (el.dotDotDotToken !== undefined) found = true; // object rest of a tracked receiver + if (el.propertyName !== undefined && ts.isComputedPropertyName(el.propertyName) && staticKeyText(el.propertyName) === null) { + found = true; // indeterminate computed key (unchanged A2 predicate, at any depth) + } + } + if (ts.isObjectBindingPattern(el.name) || ts.isArrayBindingPattern(el.name)) scanReqResBindingPattern(el.name); + } + }; + + // RULE A3 (createServer OPTIONS literal — MECH-1 disposition). Node reads `options.IncomingMessage` / + // `options.ServerResponse` with an ordinary property GET and constructs the supplied class with + // the LIVE connection socket / the request, so the options literal is a socket/request-capability + // delivery surface. Each ObjectLiteralElementLike is dispositioned by its FINITE AST KIND and its + // KEY only — the element VALUE / body / RHS is never inspected, and no prototype is traversed: + // - SpreadAssignment `{ ...x }` → DENY: an unsupported (value-flow) shape at the privileged + // boundary; nothing is claimed about `x`. + // - PropertyAssignment whose NON-COMPUTED identifier/string name is `__proto__` → DENY + // regardless of value: this is the one literal form that sets the options PROTOTYPE, so + // Node's ordinary read walks into a caller-controlled object. Computed `['__proto__']`, + // shorthand `{ __proto__ }`, a `__proto__` accessor, and a `__proto__` concise method all + // define an ORDINARY own property (never the prototype) and stay allowed. + // - an INDETERMINATE computed key (runtime / ambient / call-result / resource abort), on ANY + // element kind → DENY: a key that MIGHT be a reserved name at runtime must not pass by being + // unresolvable at a privileged boundary. + // - a RESOLVED `IncomingMessage` / `ServerResponse` key on a data property (PropertyAssignment / + // ShorthandPropertyAssignment) or a GETTER → DENY (the already-supported delivering kinds). + // A SET-only accessor reads as `undefined` (Node falls back to its own default) and a concise + // METHOD is not constructable (`new` throws), so the reserved-key setter/method forms deliver + // no usable constructor and stay allowed; a numeric/bigint/benign key is harmless; an exact-set + // test never matches a superstring key (`IncomingMessageLimit`). + // Keys are resolved by the SAME `staticKeyText`/`sockResolveKey` machinery as every other SOCK key. + const scanCreateServerOptions = (options: ts.ObjectLiteralExpression): void => { + for (const prop of options.properties) { + if (ts.isSpreadAssignment(prop)) { + found = true; // unsupported shape at the privileged boundary + continue; + } + if (ts.isShorthandPropertyAssignment(prop)) { + if (CONSTRUCTOR_INJECTION_OPTIONS.has(prop.name.text)) found = true; + continue; + } + // PropertyAssignment / GetAccessorDeclaration / SetAccessorDeclaration / MethodDeclaration + let name: string | null; + if (ts.isComputedPropertyName(prop.name)) { + const key = sockResolveKey(prop.name.expression, checker, sockMemo); + if (key.kind === 'indeterminate') { + found = true; // fail-closed: an unresolvable key at the privileged boundary + continue; + } + name = key.kind === 'resolved' ? key.value : null; // NotCapability: provably not a reserved name + } else { + name = staticKeyText(prop.name); + if (ts.isPropertyAssignment(prop) && name === '__proto__') found = true; // prototype-setting data key + } + if ( + name !== null && + CONSTRUCTOR_INJECTION_OPTIONS.has(name) && + (ts.isPropertyAssignment(prop) || ts.isGetAccessorDeclaration(prop)) + ) { + found = true; + } + } + }; + + // Pass 1 — MECH-1 createServer ARGUMENT disposition (ROOT-FAMILY-CS-ARGUMENT-RESOLUTION + Day-7 + // fail-closed boundary), and RULE A2 support (collect the request/response parameter symbols). + // Every argument of a proven createServer call/new is first normalized through the EXISTING + // bounded `resolveCreateServerArgument` (transparent wrappers, unique-`const` spines, a unique + // `FunctionDeclaration`) and then classified by FINITE AST SHAPE — nothing else: + // - FUNCTION-LIKE (ArrowFunction / FunctionExpression / FunctionDeclaration) → the request + // listener: a direct identifier parameter is tracked as a req/res symbol; a destructured + // parameter PATTERN is walked by `scanReqResBindingPattern` (object rest / indeterminate key + // fail closed), and its socket-named elements are RULE A (c) binding elements as before. + // - OBJECT LITERAL → the options object: `scanCreateServerOptions`. + // - ANY OTHER SHAPE → DENY. A `let`/`var` binding, a parameter, a default parameter, a call + // result, a member/element access, a `new` expression (class instance, Proxy), a conditional, + // a spread argument, an ambient binding, a primitive literal, … — we make NO claim about what + // the shape EVALUATES to; it is rejected solely because an unsupported shape is being supplied + // to a privileged createServer capability boundary. This replaces the former "stays outside the + // proof" allowance for these shapes (the frozen positive model denied only what it could + // prove; the Day-7 boundary fails closed on what it cannot). No value-flow analysis is + // introduced: the resolver is unchanged, and an unresolvable argument simply never becomes a + // function-like or object-literal node. const reqResSymbols = new Set(); - const collect = (node: ts.Node): void => { - if (isCreateServerCall(node)) { - for (const arg of (node as ts.CallExpression).arguments) { - const handler = resolveCreateServerArgument(arg); - if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler) || ts.isFunctionDeclaration(handler)) { - for (const param of handler.parameters) { - if (ts.isIdentifier(param.name)) { - const symbol = checker.getSymbolAtLocation(param.name); - if (symbol !== undefined) reqResSymbols.add(symbol); - } - } + const disposeCreateServerArgument = (arg: ts.Expression): void => { + const spine: ts.VariableDeclaration[] = []; + const source = resolveCreateServerArgument(arg, spine); + // Day-7 F1 — an AMBIENT or BODILESS source is NOT a supported shape. A `declare function listener` + // (or `export declare function`, a `declare global`/`declare namespace` member, a `declare const + // h = …` initializer) and a bodiless overload signature emit NO runtime value in this file, so + // the runtime binding the emitted `createServer(listener)` reaches is whatever the global + // environment supplies — its parameters are not the parameters of the code that will run. The + // resolved node is dispositioned by the EXISTING ambient predicate (`isInAmbientContext`, walks + // the `declare` modifier up the ancestors) and the structural `body === undefined` test for a + // FunctionDeclaration (an ArrowFunction / FunctionExpression always carries a body); both are + // purely syntactic — no body analysis, no value flow — and the shape falls to the same + // fail-closed unsupported-shape branch below. + if (isInAmbientContext(source) || (ts.isFunctionDeclaration(source) && source.body === undefined)) { + found = true; // ambient / bodiless source at the privileged createServer boundary + return; + } + if (ts.isArrowFunction(source) || ts.isFunctionExpression(source) || ts.isFunctionDeclaration(source)) { + for (const param of source.parameters) { + if (ts.isIdentifier(param.name)) { + const symbol = checker.getSymbolAtLocation(param.name); + if (symbol !== undefined) reqResSymbols.add(symbol); + } else { + scanReqResBindingPattern(param.name); } } + return; + } + if (ts.isObjectLiteralExpression(source)) { + // Day-7 MECH-1 const-spine confinement: a literal reached through one or more `const` hops is a + // supported source only while every spine binding stays confined to the spine and the boundary + // (see `optionsSpineIsConfined`); an inline literal (empty spine) has no binding to escape. + if (spine.length > 0 && !optionsSpineIsConfined(spine)) { + found = true; // the options object may have been mutated / its identity escaped: fail closed + return; + } + scanCreateServerOptions(source); + return; + } + found = true; // unsupported argument shape at the privileged createServer boundary + }; + const collect = (node: ts.Node): void => { + if (isCreateServerCall(node)) { + for (const arg of node.arguments ?? []) disposeCreateServerArgument(arg); } ts.forEachChild(node, collect); }; ts.forEachChild(sourceFile, collect); + // Whether an expression is a tracked req/res RECEIVER: the tracked parameter identifier itself + // (unchanged), or — MECH-2 (Day-7 convergence) — a bounded STATIC MEMBER CHAIN rooted at one: + // dotted / optional-chained property hops (`res.req`, `req.headers`) and element hops whose key + // RESOLVES statically through the shared bounded `sockResolveKey` (`res['req']`, `res['r' + 'eq']`, + // `const k = 'req'; res[k]`), through the transparent wrappers `binderUnwrap` strips. This closes + // `res.req[k]` (ServerResponse exposes its IncomingMessage as `.req`) so RULE A2's fail-closed + // disposition follows the static chain. It is purely syntactic and finite (each step descends one + // AST level of ONE expression; no declaration is followed): an alias (`const r = res.req; r[k]`), a + // call in the chain (`res.getHeader(x)[k]`), an array wrapper, or a spread copy is NOT a chain and + // stays at the frozen boundary. An INDETERMINATE element hop stops the chain — that inner access + // is itself rejected by RULE A2 when its own receiver is tracked, so nothing is lost by stopping. const receiverIsReqRes = (expr: ts.Expression): boolean => { - const e = binderUnwrap(expr); + let e = binderUnwrap(expr); + for (;;) { + if (ts.isPropertyAccessExpression(e)) { + e = binderUnwrap(e.expression); + continue; + } + if (ts.isElementAccessExpression(e)) { + if (sockResolveKey(e.argumentExpression, checker, sockMemo).kind !== 'resolved') return false; + e = binderUnwrap(e.expression); + continue; + } + break; + } if (!ts.isIdentifier(e)) return false; const symbol = checker.getSymbolAtLocation(e); return symbol !== undefined && reqResSymbols.has(symbol); }; - let found = false; + // RULE A2 (assignment-target walk — MECH-2 object-rest + nested fail-close) — the assignment twin of + // `scanReqResBindingPattern`, walked over the finite ObjectLiteral/ArrayLiteral DESTRUCTURING + // TARGET of an `=` whose right-hand side is a tracked req/res receiver. Per property: a + // SpreadAssignment `{ ...rest }` REJECTS (object rest of a tracked receiver, never propagated); a + // `socket`/`connection`/`client` key rejects; an indeterminate computed key fails closed; a static + // harmless key is allowed — byte-for-byte the former top-level rules, now also applied to a NESTED + // object-literal target (`({ req: { ...rest } } = res)`), through a nested array target, and + // through a defaulted nested target (`({ req: { ...rest } = {} } = res)`, whose target is the LEFT + // of the inner `=`). Finite: it descends only nested literal targets and terminates at the leaves. + const scanReqResAssignmentTarget = (value: ts.Expression): void => { + let target = binderUnwrap(value); + if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + target = binderUnwrap(target.left); // a defaulted nested target: `{ … } = default` + } + if (ts.isArrayLiteralExpression(target)) { + for (const el of target.elements) scanReqResAssignmentTarget(el); + return; + } + if (!ts.isObjectLiteralExpression(target)) return; + for (const prop of target.properties) { + if (ts.isSpreadAssignment(prop)) { + found = true; // object rest of a tracked receiver + } else if (ts.isShorthandPropertyAssignment(prop)) { + if (SOCKET_CAPABILITY_NAMES.has(prop.name.text)) found = true; + } else if (ts.isPropertyAssignment(prop)) { + const key = staticKeyText(prop.name); + if (ts.isComputedPropertyName(prop.name)) { + if (key === null || SOCKET_CAPABILITY_NAMES.has(key)) found = true; + } else if (key !== null && SOCKET_CAPABILITY_NAMES.has(key)) { + found = true; + } + scanReqResAssignmentTarget(prop.initializer); + } + } + }; // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — the assignment-AST twin of the // RULE A (c) per-binding-element key check, walked over the finite ObjectLiteralExpression // destructuring TARGET of an `=`, but DELIBERATELY SCOPED to the receiver-independent @@ -1493,10 +1855,13 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // the finite AST depth with NO value flow, alias following, or receiver tracking. An INDETERMINATE // computed key is NOT globally failed closed here (only a Resolved delivery name rejects); the // req/res A2 branch below retains its own fail-closed behavior. + // Day-7 F2: the receiver-independent set is `RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES` = the delivery + // members plus `constructor` (family iii), so `({ constructor: S } = server)` is rejected exactly like + // `({ on: register } = server)`; the socket-value exclusion above is unchanged. const scanSocketAssignmentTarget = (target: ts.ObjectLiteralExpression): void => { for (const prop of target.properties) { if (ts.isShorthandPropertyAssignment(prop)) { - if (SOCKET_DELIVERY_MEMBERS.has(prop.name.text)) found = true; + if (RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES.has(prop.name.text)) found = true; } else if (ts.isPropertyAssignment(prop)) { let name: string | null = null; if (ts.isComputedPropertyName(prop.name)) { @@ -1505,63 +1870,21 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } else { name = staticKeyText(prop.name); } - if (name !== null && SOCKET_DELIVERY_MEMBERS.has(name)) found = true; + if (name !== null && RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES.has(name)) found = true; const value = binderUnwrap(prop.initializer); if (ts.isObjectLiteralExpression(value)) scanSocketAssignmentTarget(value); } } }; const visit = (node: ts.Node): void => { - // RULE A3 — CONSTRUCTOR-INJECTION options on a permitted createServer call. Node constructs a - // supplied `IncomingMessage` subclass with the LIVE connection socket and a supplied - // `ServerResponse` subclass with the request, so an option naming either is a socket/request - // capability delivery. Only an OBJECT-LITERAL argument is inspected — the requestListener - // FUNCTION argument is skipped, and the check runs ONLY when `isCreateServerCall` already - // proved this is the node:http createServer capability, so an unrelated `foo.createServer({ - // IncomingMessage })` and any object literal OUTSIDE a createServer call are untouched. A - // property KEY is resolved by the SAME `staticKeyText`/`sockResolveKey` machinery as every - // other SOCK key (plain/quoted key is its own text; a computed key folds off the binder); a - // RESOLVED `IncomingMessage`/`ServerResponse` name REJECTS at this acquisition site — the - // supplied class value, its body, and its constructor are NEVER inspected (no taint/type/ - // whole-program, no alias following). A runtime/indeterminate key is no match here (unchanged - // SOCK disposition, not globally failed closed), and the exact-set test never matches a - // superstring key (`IncomingMessageLimit`). - if (isCreateServerCall(node)) { - for (const arg of (node as ts.CallExpression).arguments) { - // ROOT-FAMILY-CS-ARGUMENT-RESOLUTION (F2): normalize the argument through the bounded - // createServer argument resolver first, so a unique-`const`-bound alias of the options - // object (`const options = { IncomingMessage: Capture }; createServer(options as any, …)`, - // including a computed-static option key and a const→const spine) is reserved exactly like - // a direct object literal. A `let`/parameter/call-result/spread options argument does not - // resolve to an ObjectLiteralExpression and stays outside the proof (frozen positive model). - const optionsObject = resolveCreateServerArgument(arg); - if (!ts.isObjectLiteralExpression(optionsObject)) continue; - for (const prop of optionsObject.properties) { - // PROPERTY-KIND PARITY (Codex P1): reserve the constructor-option KEY for every element whose - // property READ delivers the caller's value to Node — a data property (PropertyAssignment / - // ShorthandPropertyAssignment) OR a GETTER (GetAccessorDeclaration: Node reads the option - // with an ordinary property GET, which RUNS the getter, then constructs its return with the - // live connection socket). A SET-only accessor (property read is `undefined`, so Node falls - // back to its own default), an object-literal concise MethodDeclaration (a concise method is - // NOT constructable — `new` throws), and a SpreadAssignment (outside the frozen value-flow - // boundary) deliver no usable constructor and are skipped. The element VALUE/body is never - // inspected; only the KEY is resolved, by the same staticKeyText/sockResolveKey machinery. - if ( - !ts.isPropertyAssignment(prop) && - !ts.isShorthandPropertyAssignment(prop) && - !ts.isGetAccessorDeclaration(prop) - ) continue; - let name: string | null; - if (!ts.isShorthandPropertyAssignment(prop) && ts.isComputedPropertyName(prop.name)) { - const key = sockResolveKey(prop.name.expression, checker, sockMemo); - name = key.kind === 'resolved' ? key.value : null; - } else { - name = staticKeyText(prop.name); - } - if (name !== null && CONSTRUCTOR_INJECTION_OPTIONS.has(name)) found = true; - } - } - } + // RULE A3 — CONSTRUCTOR-INJECTION options on a permitted createServer call/new. The check runs + // ONLY where `isCreateServerCall` proved the node:http createServer capability, so an unrelated + // `foo.createServer({ IncomingMessage })` and any object literal OUTSIDE a createServer call are + // untouched. MECH-1 (Day-7): the whole createServer argument disposition — the options-literal + // reservation (property-kind parity, `__proto__`, spread, indeterminate key), the listener + // parameter collection, and the fail-closed unsupported-shape boundary — is decided ONCE in + // pass 1 (`disposeCreateServerArgument` / `scanCreateServerOptions` above), for the call AND + // `new` forms, so this pass no longer re-inspects createServer arguments. // RULE A (a) — GLOBAL dotted socket-acquisition NAME: `.socket`/`.connection` or a delivery // member `.on`/`.once`/`.addListener`/`.prependListener`/`.prependOnceListener`/ // `.setTimeout` (optional chaining included), any receiver, any position. This is the node @@ -1626,15 +1949,9 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou node.initializer !== undefined && receiverIsReqRes(node.initializer) ) { - for (const el of node.name.elements) { - if ( - el.propertyName !== undefined && - ts.isComputedPropertyName(el.propertyName) && - staticKeyText(el.propertyName) === null - ) { - found = true; - } - } + // MECH-2 (Day-7): the same top-level predicate, walked through the finite pattern by + // `scanReqResBindingPattern` — plus the object-rest rejection — at every nesting depth. + scanReqResBindingPattern(node.name); } // RULE A2 (assignment) — an object DESTRUCTURING ASSIGNMENT `({ socket: s } = req)` reads // the property off the req/res param exactly like a declaration destructuring, but the @@ -1648,22 +1965,10 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou node.operatorToken.kind === ts.SyntaxKind.EqualsToken && receiverIsReqRes(node.right) ) { - const target = binderUnwrap(node.left); - if (ts.isObjectLiteralExpression(target)) { - for (const prop of target.properties) { - if (ts.isShorthandPropertyAssignment(prop)) { - if (SOCKET_CAPABILITY_NAMES.has(prop.name.text)) found = true; - } else if (ts.isPropertyAssignment(prop)) { - if (ts.isComputedPropertyName(prop.name)) { - const key = staticKeyText(prop.name); - if (key === null || SOCKET_CAPABILITY_NAMES.has(key)) found = true; - } else { - const key = staticKeyText(prop.name); - if (key !== null && SOCKET_CAPABILITY_NAMES.has(key)) found = true; - } - } - } - } + // MECH-2 (Day-7): the same top-level rules, walked through the finite literal target by + // `scanReqResAssignmentTarget` — plus the object-rest (SpreadAssignment) rejection — at every + // nesting depth. + scanReqResAssignmentTarget(node.left); } // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — receiver-INDEPENDENT // delivery/registrar-member NAME in an object DESTRUCTURING ASSIGNMENT target: @@ -1690,6 +1995,53 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou // `.call`/`.apply`/`.bind`/`Reflect.apply`/method-extraction indirection (F2) and the // `setTimeout` delivery surface (F1) — with NO witness-specific `.call`/`.apply`/`.bind` // blacklist and NO event-name enumeration. No separate call-shaped rule remains. + // MECH-2 (Day-7) — `arguments` delivery. A non-arrow function receives its request/response + // objects through the `arguments` object as well as through its named parameters, so a + // listener (or any function the listener reaches) can recover `req`/`res` with `arguments[0]` + // without a tracked parameter ever appearing. In this strict ESM host `arguments` has no + // legitimate use, so a value-position Identifier whose text is exactly `arguments` is a + // WHOLE-FILE syntactic reservation — no alias / data-flow analysis (`const a = arguments`, + // `const [req] = arguments`, an arrow capturing it, `Reflect.get(arguments, 0)` are all caught at + // the read of the identifier itself). The two existing name-position guards are BOTH required — + // `isValueReference` (RC/HA: declaration names, class members, type-position members, + // object-literal keys, member names) and `isBinderValueReference` (NET: additionally the + // destructuring SOURCE key `{ arguments: a }` and import/export specifiers) — so a property + // NAME (`obj.arguments`), an object-literal / destructuring KEY, a declaration name, a + // type-position member, and a string literal are all kept out: none of those read the binding. + // An unrelated `arguments.length` is an accepted policy false positive (no host/cockpit source + // uses `arguments`). + if (ts.isIdentifier(node) && node.text === 'arguments' && isValueReference(node) && isBinderValueReference(node)) { + found = true; + } + // MECH-2 (Day-7) — STRUCTURAL reflective acquisition. A direct call to the FREE, unshadowed + // built-in `Reflect.get` / `Reflect.getOwnPropertyDescriptor` / `Object.getOwnPropertyDescriptor` + // (`isSockReflectiveReadCallee`: base identifier binder-proven unshadowed, member name resolved + // statically to the approved API — dotted or bounded static element form) reads a property by a + // KEY ARGUMENT instead of by a syntactic member name, so RULE A's name ban never sees it. The + // property-key argument is classified by the SAME bounded `sockResolveKey`: a key that RESOLVES + // to a static socket-acquisition name DENIES on ANY target (`Reflect.get(server, 'on')`, exactly + // as `server['on']` does); an INDETERMINATE key DENIES only on a tracked req/res target (RULE A2 + // parity — `Reflect.get(unrelated, k)` stays at the frozen boundary); a resolved harmless key + // (`'method'`) is allowed. A spread in the target/key position is an unsupported argument shape + // at this reflective boundary and fails closed. This is NOT the rejected broad "deny any call + // argument whose string equals socket/on/…" rule: `res.setHeader('connection', 'close')`, + // `map.get('connection')`, `log('socket')`, a user-shadowed `Reflect`/`Object`, and an aliased + // `const R = Reflect` (frozen) are untouched — only the recognized free-builtin callee STRUCTURE + // is inspected, and nothing is followed after the acquisition. + if (ts.isCallExpression(node) && isSockReflectiveReadCallee(node.expression, checker, sourceFile)) { + const target = node.arguments[0]; + const keyArg = node.arguments[1]; + if ((target !== undefined && ts.isSpreadElement(target)) || (keyArg !== undefined && ts.isSpreadElement(keyArg))) { + found = true; // unsupported argument shape at the reflective boundary + } else if (keyArg !== undefined) { + const key = sockResolveKey(keyArg, checker, sockMemo); + if (key.kind === 'resolved') { + if (STATIC_SOCKET_ACQUISITION_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate' && target !== undefined && receiverIsReqRes(target)) { + found = true; + } + } + } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); @@ -2010,6 +2362,54 @@ const sockResolveKey = ( } }; +// MECH-2 (Day-7 convergence, SOCK reflective acquisition) — the FINITE family of free built-in +// reflective READ APIs that acquire a property by a KEY ARGUMENT (bypassing RULE A's syntactic member +// name): `Reflect.get`, `Reflect.getOwnPropertyDescriptor`, `Object.getOwnPropertyDescriptor`. This is a +// closed inventory keyed by base identifier; it is NOT a general reflective/value-flow model +// (`Reflect.has`/`ownKeys`, `Object.values`/`entries`/`assign`/`getOwnPropertyDescriptors`, an aliased +// `const R = Reflect`, a `.call`/`.apply`/`.bind` chain, and a wrapper all stay at the frozen boundary). +const SOCK_REFLECTIVE_READ_APIS: ReadonlyMap> = new Map([ + ['Reflect', new Set(['get', 'getOwnPropertyDescriptor'])], + ['Object', new Set(['getOwnPropertyDescriptor'])], +]); +// The longest API name (`getOwnPropertyDescriptor` = 24), DERIVED from the inventory so a static +// element-access spelling of the member (`Object['getOwnProperty' + 'Descriptor']`) folds to it rather +// than being pruned as NotCapability under the narrower socket/network ceilings. +const MAX_SOCK_REFLECTIVE_API_LENGTH = Math.max( + ...[...SOCK_REFLECTIVE_READ_APIS.values()].flatMap((names) => [...names]).map((name) => name.length), +); +// Whether a call callee is a DIRECT free built-in reflective READ member: a dotted `Reflect.get` / +// `Object.getOwnPropertyDescriptor` (optional chaining included) or a bounded static element access +// `Reflect['get']` / `Object['getOwnProperty' + 'Descriptor']`, with the member name resolved by the +// SAME `netResolveKey` machinery (binder identity, never identifier text) at the API-name ceiling, off a +// BASE identifier the binder proves UNSHADOWED — the `isFreeReflect` identifier rule reused verbatim +// (`hasLocalRuntimeShadow`): a local `const Reflect = { get() {} }` / `const Object = …` is an ordinary +// object. An aliased base (`const R = Reflect`), a runtime member key (`Reflect[k]`), or a shadowed base +// all fail this — one statically identifiable built-in member, no alias, no call/apply/bind, no wrapper. +const isSockReflectiveReadCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const base = binderUnwrap(c.expression); + if (!ts.isIdentifier(base)) return false; + const apis = SOCK_REFLECTIVE_READ_APIS.get(base.text); + if (apis === undefined) return false; + const symbol = checker.getSymbolAtLocation(base); + if (symbol !== undefined && hasLocalRuntimeShadow(symbol, sourceFile)) return false; + let member: string | null; + if (ts.isPropertyAccessExpression(c)) { + member = c.name.text; + } else { + try { + const key = netResolveKey(c.argumentExpression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_SOCK_REFLECTIVE_API_LENGTH); + member = key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (!(error instanceof NetResolveAbort)) throw error; + member = null; + } + } + return member !== null && apis.has(member); +}; + // DDR-NET-STATIC-KEY-PARITY (nested authority) — the self-reference HOP NAME a DESTRUCTURING key // denotes, resolved by the SAME bounded binder resolver used for a member-access hop (`netHopName`) // but reading a binding/object-literal KEY node instead of an element-access argument: a plain @@ -6249,7 +6649,6 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'an unrelated foo.createServer({ IncomingMessage: X }) that is not the node:http capability', source: `const foo = { createServer(_o: unknown, _h: () => void): void {} };\nclass X {}\nfoo.createServer({ IncomingMessage: X }, () => {});` }, { form: 'an object literal with an IncomingMessage key OUTSIDE any createServer call', source: H + `class X {}\nconst options = { IncomingMessage: X };\nvoid options;\nhttp.createServer(() => {});` }, { form: 'a benign superstring key { IncomingMessageLimit: 10 } (exact-set, not substring)', source: H + `http.createServer({ IncomingMessageLimit: 10 } as any, () => {});` }, - { form: 'a genuinely runtime option key { [runtimeKey]: X } stays outside the proof', source: H + `declare const runtimeKey: string;\nclass X {}\nhttp.createServer({ [runtimeKey]: X } as any, () => {});` }, ]; for (const { form, source } of injectionAllow) { it(`allows ${form}`, () => { @@ -6315,7 +6714,6 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 const accessorInjectionAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a SET-only accessor { set IncomingMessage(v) {} } (property read is undefined → default)', source: H + `http.createServer({ set IncomingMessage(_v: any) {} } as any, () => {});` }, { form: 'a concise METHOD { IncomingMessage() {} } (a concise method is not constructable)', source: H + `http.createServer({ IncomingMessage() {} } as any, () => {});` }, - { form: 'a SPREAD element { ...base } carrying a getter stays outside the value-flow boundary', source: H + `class Capture {}\nconst base = { get IncomingMessage() { return Capture; } };\nhttp.createServer({ ...base } as any, () => {});` }, { form: 'a benign non-reserved getter { get maxHeaderSize() { return 8192; } }', source: H + `http.createServer({ get maxHeaderSize() { return 8192; } } as any, () => {});` }, { form: 'a superstring-key getter { get IncomingMessageLimit() { … } } (exact-set miss)', source: H + `http.createServer({ get IncomingMessageLimit() { return 10; } } as any, () => {});` }, { form: 'a reserved-key getter OUTSIDE any createServer call', source: H + `class X {}\nconst options = { get IncomingMessage() { return X; } };\nvoid options;\nhttp.createServer(() => {});` }, @@ -6360,8 +6758,6 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'a benign FunctionDeclaration handler (no socket capability)', source: H + `function handler(req: any, res: any) {\n res.end('ok');\n}\nhttp.createServer(handler);` }, { form: 'a benign const-arrow handler (static harmless key)', source: H + `const handler = (req: any, res: any) => {\n const m = req['method'];\n void m;\n};\nhttp.createServer(handler);` }, { form: 'the inline-arrow baseline with a benign body (unchanged)', source: H + `http.createServer((req: any, res: any) => {\n res.end('ok');\n});` }, - { form: 'a MUTABLE let handler with req[runtimeKey] stays outside the proof', source: H + RK + `let handler = (req: any, res: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, - { form: 'a parameter-supplied handler stays outside the proof', source: H + RK + `function mount(handler: any) {\n http.createServer(handler);\n}\nvoid mount;` }, ]; for (const { form, source } of namedHandlerAllow) { it(`allows ${form}`, () => { @@ -6397,10 +6793,6 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 const aliasedOptionsAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'a benign const options alias { maxHeaderSize: 8192 }', source: H + `const options = { maxHeaderSize: 8192 };\nhttp.createServer(options, () => {});` }, { form: 'a benign const alias superstring key { IncomingMessageLimit: 10 }', source: H + `const options = { IncomingMessageLimit: 10 };\nhttp.createServer(options as any, () => {});` }, - { form: 'a MUTABLE let options alias stays outside the proof', source: H + `class Capture {}\nlet options = { IncomingMessage: Capture };\nhttp.createServer(options as any, () => {});` }, - { form: 'a parameter-supplied options object stays outside the proof', source: H + `function mount(options: any) {\n http.createServer(options as any, () => {});\n}\nvoid mount;` }, - { form: 'a call-result options object stays outside the proof', source: H + `declare function getOptions(): any;\nhttp.createServer(getOptions(), () => {});` }, - { form: 'a spread-copied const options object stays outside the proof (spread not value-followed)', source: H + `class Capture {}\nconst base = { IncomingMessage: Capture };\nconst options = { ...base };\nhttp.createServer(options as any, () => {});` }, { form: 'a const options object with the key OUTSIDE any createServer call', source: H + `class X {}\nconst options = { IncomingMessage: X };\nvoid options;\nhttp.createServer(() => {});` }, ]; for (const { form, source } of aliasedOptionsAllow) { @@ -6408,6 +6800,696 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(false); }); } + + // ===================================================================================== + // DAY-7 FINAL CONVERGENCE (PR #64) — two mechanisms established by the independent Day-7 audits. + // + // MECH-1 — createServer CALL / ARGUMENT / OPTIONS disposition. The privileged node:http + // createServer boundary is the CallExpression OR NewExpression whose callee is binder-proven + // CREATE_SERVER. Every argument is normalized through the EXISTING bounded + // `resolveCreateServerArgument` and then classified by FINITE AST SHAPE: a function-like node + // (ArrowFunction / FunctionExpression / FunctionDeclaration) is the request listener; an + // ObjectLiteralExpression is the options object; ANY OTHER SHAPE is DENIED at the boundary — + // no claim is made about what the shape evaluates to, it is rejected solely because an + // unsupported shape is being supplied to a privileged capability boundary (no value flow). + // Inside an options literal a SpreadAssignment, a non-computed `__proto__` data key (any + // value, never inspected), an INDETERMINATE computed key, and the resolved reserved + // `IncomingMessage`/`ServerResponse` data/shorthand/getter keys DENY; the setter-only and + // concise-method reserved forms, computed `['__proto__']`, shorthand `{ __proto__ }`, and + // benign keys stay allowed. No prototype traversal, no `__proto__` RHS inspection. + // + // MECH-2 — request/socket DELIVERY inventory. `client` joins the socket-value name family; + // `receiverIsReqRes` accepts a bounded STATIC member chain rooted at a tracked req/res param + // (closing `res.req[k]`); an OBJECT-REST binding derived from a tracked receiver in the + // parameter / declaration / assignment forms DENIES (the rest object is never propagated); + // a value-position `arguments` identifier is a whole-file syntactic reservation (strict ESM + // host — no alias/data-flow analysis); and a STRUCTURAL reflective read through the free, + // unshadowed built-ins `Reflect.get` / `Reflect.getOwnPropertyDescriptor` / + // `Object.getOwnPropertyDescriptor` DENIES when its property-key argument resolves (via the + // existing bounded socket key resolver) to a static socket-acquisition name on ANY target, or + // is INDETERMINATE on a tracked req/res target. The rejected broad rule "deny any call + // argument whose string equals socket/on/…" is NOT implemented (`res.setHeader('connection', + // 'close')`, `map.get('connection')`, `log('socket')` stay allowed). + // + // FROZEN (deliberately NOT closed here): `const r = req; r[k]`, `[req][0][k]`, `{...req}` then + // indexing, `Object.values/entries/assign/getOwnPropertyDescriptors(req)`, for-in recovery, + // req forwarded to non-builtin callees, aliased Reflect (`const R = Reflect`), runtime + // `server[k]` / `server[String(…)]`, cross-file flow. + // ===================================================================================== + + // ---- MECH-1 (1): NewExpression parity — `new http.createServer(…)` is the SAME privileged + // boundary as the call form (the constructor position is already a NET-safe position). ---- + const newExpressionReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'N1: new http.createServer({ IncomingMessage: Capture }, …) reserved option', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nvoid new http.createServer({ IncomingMessage: Capture } as any, () => {});` }, + { form: 'N2: new http.createServer(function (req) { req[runtimeKey] }) tracks the listener param', source: H + RK + `void new http.createServer(function (req: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'N3: new http.createServer(letListener) unsupported shape', source: H + RK + `let handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nvoid new http.createServer(handler);` }, + { form: 'N4: new http.createServer({ __proto__: proto }) prototype option', source: H + `class Capture {}\nconst proto = { IncomingMessage: Capture };\nvoid new http.createServer({ __proto__: proto } as any, () => {});` }, + ]; + for (const { form, source } of newExpressionReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 (2): `__proto__` constructor options — a NON-COMPUTED identifier / string + // `__proto__` data key sets the options PROTOTYPE, so Node's ordinary property read of + // `options.IncomingMessage` walks into a caller-controlled object. The key is reserved + // REGARDLESS of its value (the RHS is never inspected, no prototype recursion). ---- + const protoOptionReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'P1: an identifier key { __proto__: { IncomingMessage: Capture } }', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ __proto__: { IncomingMessage: Capture } } as any, () => {});` }, + { form: "P2: a string key { '__proto__': { IncomingMessage: Capture } }", source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ '__proto__': { IncomingMessage: Capture } } as any, () => {});` }, + { form: 'P3: a const-resolved prototype witness { __proto__: proto }', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nconst proto = { IncomingMessage: Capture };\nhttp.createServer({ __proto__: proto } as any, () => {});` }, + { form: 'P4: a nested prototype witness { __proto__: { __proto__: { IncomingMessage: Capture } } }', source: H + `class Capture {\n constructor(socket: any) {\n socket.connect(80, 'example.com');\n }\n}\nhttp.createServer({ __proto__: { __proto__: { IncomingMessage: Capture } } } as any, () => {});` }, + { form: 'P5: a const options alias carrying { __proto__: proto } (bounded arg resolver)', source: H + `class Capture {}\nconst proto = { IncomingMessage: Capture };\nconst options = { __proto__: proto };\nhttp.createServer(options as any, () => {});` }, + { form: 'P6: { __proto__: null } is reserved regardless of value (value never inspected)', source: H + `http.createServer({ __proto__: null } as any, () => {});` }, + { form: 'P7: a __proto__ key on the options-first single-argument overload', source: H + `class Capture {}\nhttp.createServer({ __proto__: { IncomingMessage: Capture } } as any);` }, + { form: 'P8: a __proto__ key beside a benign key { maxHeaderSize: 1, __proto__: proto }', source: H + `class Capture {}\nconst proto = { IncomingMessage: Capture };\nhttp.createServer({ maxHeaderSize: 1, __proto__: proto } as any, () => {});` }, + ]; + for (const { form, source } of protoOptionReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 (3): createServer OPTIONS privileged-boundary denial — an options argument whose + // normalized shape is not an ObjectLiteralExpression, or an ObjectLiteralExpression carrying + // a spread / indeterminate computed key, is an UNSUPPORTED shape at the privileged boundary + // and fails CLOSED. Nothing about the value is claimed or followed. ---- + const optionsBoundaryReject: readonly { readonly form: string; readonly source: string }[] = [ + // The FOUR former `aliasedOptionsAllow` / `injectionAllow` / `accessorInjectionAllow` ALLOW + // assertions the Day-7 audit identified as unsupported privileged shapes — flipped to DENY here + // (see the H section of the repair report). Their sources are byte-identical to the removed entries. + { form: 'O1 (flipped ALLOW→DENY): a spread literal { ...base } carrying a getter', source: H + `class Capture {}\nconst base = { get IncomingMessage() { return Capture; } };\nhttp.createServer({ ...base } as any, () => {});` }, + { form: 'O2 (flipped ALLOW→DENY): an indeterminate computed option key { [runtimeKey]: X }', source: H + `declare const runtimeKey: string;\nclass X {}\nhttp.createServer({ [runtimeKey]: X } as any, () => {});` }, + { form: 'O3 (flipped ALLOW→DENY): a MUTABLE let options alias', source: H + `class Capture {}\nlet options = { IncomingMessage: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: 'O4 (flipped ALLOW→DENY): a parameter-supplied options object', source: H + `function mount(options: any) {\n http.createServer(options as any, () => {});\n}\nvoid mount;` }, + { form: 'O5 (flipped ALLOW→DENY): a call-result options object', source: H + `declare function getOptions(): any;\nhttp.createServer(getOptions(), () => {});` }, + { form: 'O6 (flipped ALLOW→DENY): a spread-copied const options object', source: H + `class Capture {}\nconst base = { IncomingMessage: Capture };\nconst options = { ...base };\nhttp.createServer(options as any, () => {});` }, + // Remaining approved-matrix shapes. + { form: 'O7: a spread literal { ...base } (data-property base)', source: H + `class Capture {}\nconst base = { IncomingMessage: Capture };\nhttp.createServer({ ...base } as any, () => {});` }, + { form: 'O8: an indeterminate computed GETTER key { get [runtimeKey]() {} }', source: H + `declare const runtimeKey: string;\nclass X {}\nhttp.createServer({ get [runtimeKey]() { return X; } } as any, () => {});` }, + { form: 'O9: an indeterminate computed SETTER key { set [runtimeKey](v) {} } (fail-closed at the boundary)', source: H + `declare const runtimeKey: string;\nhttp.createServer({ set [runtimeKey](_v: any) {} } as any, () => {});` }, + { form: 'O10: an indeterminate computed METHOD key { [runtimeKey]() {} } (fail-closed at the boundary)', source: H + `declare const runtimeKey: string;\nhttp.createServer({ [runtimeKey]() {} } as any, () => {});` }, + { form: 'O11: a call-result computed key { [String(1)]: X }', source: H + `class X {}\nhttp.createServer({ [String(1)]: X } as any, () => {});` }, + { form: 'O12: a var-bound options object', source: H + `class Capture {}\nvar options = { IncomingMessage: Capture };\nhttp.createServer(options as any, () => {});` }, + { form: 'O13: a default-parameter options object', source: H + `class Capture {}\nfunction mount(options: any = { IncomingMessage: Capture }) {\n http.createServer(options, () => {});\n}\nvoid mount;` }, + { form: 'O14: Object.create(proto) options', source: H + `class Capture {}\nhttp.createServer(Object.create({ IncomingMessage: Capture }) as any, () => {});` }, + { form: 'O15: Object.setPrototypeOf({}, proto) options', source: H + `class Capture {}\nhttp.createServer(Object.setPrototypeOf({}, { IncomingMessage: Capture }) as any, () => {});` }, + { form: 'O16: Object.assign({}, proto) options', source: H + `class Capture {}\nhttp.createServer(Object.assign({}, { IncomingMessage: Capture }) as any, () => {});` }, + { form: 'O17: a class-instance options object new Opts()', source: H + `class Capture {}\nclass Opts {\n IncomingMessage = Capture;\n}\nhttp.createServer(new Opts() as any, () => {});` }, + { form: 'O18: a const-bound class-instance options object', source: H + `class Capture {}\nclass Opts {\n IncomingMessage = Capture;\n}\nconst options = new Opts();\nhttp.createServer(options as any, () => {});` }, + { form: 'O19: a Proxy options object', source: H + `class Capture {}\nhttp.createServer(new Proxy({}, { get: () => Capture }) as any, () => {});` }, + { form: 'O20: an array-element options object opts[0]', source: H + `class Capture {}\nconst opts = [{ IncomingMessage: Capture }];\nhttp.createServer(opts[0] as any, () => {});` }, + { form: 'O21: a member-access options object holder.options', source: H + `class Capture {}\nconst holder = { options: { IncomingMessage: Capture } };\nhttp.createServer(holder.options as any, () => {});` }, + { form: 'O22: a conditional options expression', source: H + `class Capture {}\ndeclare const flag: boolean;\nhttp.createServer((flag ? { IncomingMessage: Capture } : {}) as any, () => {});` }, + { form: 'O23: a spread ARGUMENT createServer(...args)', source: H + `class Capture {}\nconst args = [{ IncomingMessage: Capture }, () => {}] as const;\nhttp.createServer(...(args as any));` }, + { form: 'O24: a const alias of a let options binding (resolver stops at the let)', source: H + `class Capture {}\nlet base = { IncomingMessage: Capture };\nconst options = base;\nhttp.createServer(options as any, () => {});` }, + { form: 'O25: an ambient (declare const) options binding', source: H + `declare const options: any;\nhttp.createServer(options, () => {});` }, + { form: 'O26: a primitive literal argument createServer(null, …)', source: H + `http.createServer(null as any, () => {});` }, + ]; + for (const { form, source } of optionsBoundaryReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 (4): request LISTENER privileged-boundary denial — a listener argument whose + // normalized shape is not function-like is an UNSUPPORTED shape at the privileged boundary + // and fails CLOSED (its parameters cannot be tracked, so its req/res delivery is unprovable). ---- + const listenerBoundaryReject: readonly { readonly form: string; readonly source: string }[] = [ + // The TWO former `namedHandlerAllow` ALLOW assertions the Day-7 audit identified — flipped to DENY. + { form: 'L1 (flipped ALLOW→DENY): a MUTABLE let handler with req[runtimeKey]', source: H + RK + `let handler = (req: any, res: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'L2 (flipped ALLOW→DENY): a parameter-supplied handler', source: H + RK + `function mount(handler: any) {\n http.createServer(handler);\n}\nvoid mount;` }, + // Remaining approved-matrix shapes. + { form: 'L3: a wrapper-forwarded listener createServer(wrap(handler))', source: H + RK + `declare function wrap(h: any): any;\nconst handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(wrap(handler));` }, + { form: 'L4: an arrow wrapper forwarding its parameter const mount = (h) => http.createServer(h)', source: H + RK + `const mount = (h: any) => http.createServer(h);\nmount((req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'L5: an array-element listener handlers[0]', source: H + RK + `const handlers = [(req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n}];\nhttp.createServer(handlers[0]);` }, + { form: 'L6: an object method listener obj.handle', source: H + RK + `const obj = {\n handle(req: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n },\n};\nhttp.createServer(obj.handle);` }, + { form: 'L7: a class method listener new C().handle', source: H + RK + `class C {\n handle(req: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n }\n}\nhttp.createServer(new C().handle);` }, + { form: 'L8: a class prototype method listener C.prototype.handle', source: H + RK + `class C {\n handle(req: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n }\n}\nhttp.createServer(C.prototype.handle);` }, + { form: 'L9: a default-parameter listener', source: H + RK + `function mount(h: any = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n}) {\n http.createServer(h);\n}\nvoid mount;` }, + { form: 'L10: a bound listener handler.bind(null)', source: H + RK + `const handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler.bind(null));` }, + { form: 'L11: a var-bound listener', source: H + RK + `var handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'L12: a conditional listener expression', source: H + RK + `declare const flag: boolean;\nconst a = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(flag ? a : a);` }, + { form: 'L13: a listener supplied as the SECOND argument in an unsupported shape', source: H + RK + `let handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer({}, handler);` }, + { form: 'L14: a benign-looking unsupported listener shape is still denied (shape, not value)', source: H + `declare const listener: any;\nhttp.createServer(listener);` }, + ]; + for (const { form, source } of listenerBoundaryReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 (preserve): the SUPPORTED shapes — direct/const/FunctionDeclaration listeners and + // object-literal options — keep their existing disposition, for BOTH call and `new` forms; + // an unsupported shape supplied to an UNRELATED `.createServer` is untouched. ---- + const createServerShapeAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a zero-argument createServer()', source: H + `http.createServer();` }, + { form: 'a zero-argument new http.createServer()', source: H + `void new http.createServer();` }, + { form: 'a benign new http.createServer(() => {}) (NewExpression parity in the allow direction)', source: H + `void new http.createServer(() => {});` }, + { form: 'a benign new http.createServer({ maxHeaderSize: 8192 }, handler) with a FunctionDeclaration', source: H + `function handler(req: any, res: any) {\n res.end('ok');\n}\nvoid new http.createServer({ maxHeaderSize: 8192 }, handler);` }, + { form: 'a benign options + inline listener createServer({ keepAlive: true }, (req, res) => …)', source: H + `http.createServer({ keepAlive: true }, (req: any, res: any) => {\n res.end(req.method);\n});` }, + { form: 'a benign const->const listener spine', source: H + `const base = (req: any, res: any) => {\n res.end('ok');\n};\nconst handler = base;\nhttp.createServer(handler);` }, + { form: 'a benign as-wrapped FunctionDeclaration listener', source: H + `function handler(req: any, res: any) {\n res.end('ok');\n}\nhttp.createServer(handler as any);` }, + { form: 'a benign satisfies-wrapped inline listener', source: H + `http.createServer(((req: any, res: any) => {\n res.end('ok');\n}) satisfies (req: any, res: any) => void);` }, + { form: 'an unrelated foo.createServer(getOptions()) (not the privileged boundary)', source: `declare function getOptions(): any;\nconst foo = { createServer(_o: unknown): void {} };\nfoo.createServer(getOptions());` }, + { form: 'an unrelated foo.createServer(letListener) (not the privileged boundary)', source: `let listener = () => {};\nconst foo = { createServer(_h: unknown): void {} };\nfoo.createServer(listener);` }, + { form: 'a numeric option key { 1: X } is a harmless non-reserved key', source: H + `class X {}\nhttp.createServer({ 1: X } as any, () => {});` }, + { form: "a resolvable harmless computed key { ['maxHeader' + 'Size']: 1 }", source: H + `http.createServer({ ['maxHeader' + 'Size']: 1 } as any, () => {});` }, + ]; + for (const { form, source } of createServerShapeAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- MECH-2 (5): `arguments` delivery — in this strict ESM host a value-position `arguments` + // identifier is a WHOLE-FILE syntactic reservation (no alias / data-flow analysis; the + // binder value-reference guard keeps property names / keys / declarations out). ---- + const argumentsReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'G1: arguments[0][runtimeKey] inside a function-expression listener', source: H + RK + `http.createServer(function () {\n const s = arguments[0][rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G2: arguments[1][runtimeKey] (the response object)', source: H + RK + `http.createServer(function () {\n const s = arguments[1][rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G3: an alias const a = arguments; a[0][runtimeKey]', source: H + RK + `http.createServer(function () {\n const a = arguments;\n const s = a[0][rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G4: an array-destructuring alias const [req] = arguments', source: H + RK + `http.createServer(function () {\n const [req] = arguments;\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G5: an object-destructuring alias const { 0: req } = arguments', source: H + RK + `http.createServer(function () {\n const { 0: req } = arguments;\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G6: an arrow-captured arguments inside the listener', source: H + RK + `http.createServer(function () {\n const grab = () => arguments[0][rk];\n grab().connect(80, 'example.com');\n});` }, + { form: 'G7: Reflect.get(arguments, 0)', source: H + RK + `http.createServer(function () {\n const req = Reflect.get(arguments, 0);\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'G8: a named FunctionDeclaration listener using arguments', source: H + RK + `function handler() {\n const s = arguments[0][rk];\n s.connect(80, 'example.com');\n}\nhttp.createServer(handler);` }, + { form: 'G9: a const function-expression listener using arguments', source: H + RK + `const handler = function () {\n const s = arguments[0][rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'G10: a shorthand { arguments } capture', source: H + `http.createServer(function () {\n const box = { arguments };\n void box;\n});` }, + { form: 'G11: arguments forwarded as a call argument', source: H + `declare function leak(a: unknown): void;\nhttp.createServer(function () {\n leak(arguments);\n});` }, + { form: 'G12: an unrelated function reading arguments.length (accepted whole-file reservation)', source: `function sum(): number {\n return arguments.length;\n}\nvoid sum;` }, + ]; + for (const { form, source } of argumentsReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-2 (5, preserve): `arguments` in a NON-value position is not a read of the binding. ---- + const argumentsAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a member NAME obj.arguments', source: `const obj = { arguments: 1 };\nvoid obj.arguments;` }, + { form: 'an object-literal KEY { arguments: 1 }', source: `const obj = { arguments: 1 };\nvoid obj;` }, + { form: 'a destructuring source KEY const { arguments: a } = obj', source: `const obj = { arguments: 1 };\nconst { arguments: a } = obj;\nvoid a;` }, + { form: 'a type-position property signature { arguments: number }', source: `type T = { arguments: number };\ndeclare const t: T;\nvoid t;` }, + { form: 'a class METHOD named arguments', source: `class K {\n arguments(): void {}\n}\nvoid new K();` }, + { form: 'a class FIELD named arguments', source: `class K {\n arguments = 1;\n}\nvoid new K();` }, + { form: 'a class GETTER named arguments', source: `class K {\n get arguments(): number {\n return 1;\n }\n}\nvoid new K();` }, + { form: 'an object-literal GETTER / METHOD named arguments', source: `const obj = {\n get arguments(): number {\n return 1;\n },\n arguments2(): void {},\n};\nvoid obj;` }, + { form: "a string literal 'arguments' (text, not an identifier)", source: `const s = 'arguments';\nvoid s;` }, + ]; + for (const { form, source } of argumentsAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- MECH-2 (6): OBJECT-REST delivery — a rest binding element derived from a tracked req/res + // receiver (listener parameter pattern, declaration off req/res, assignment off req/res), + // at any finite pattern depth, DENIES; the resulting rest object is never propagated. ---- + const objectRestReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'R1: a listener parameter ({ ...rest }) => rest[runtimeKey]', source: H + RK + `http.createServer(({ ...rest }: any) => {\n const s = rest[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'R2: a second-parameter ({ ...rest }) response rest', source: H + RK + `http.createServer((_req: any, { ...rest }: any) => {\n const s = rest[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'R3: a mixed listener parameter ({ method, ...rest })', source: H + RK + `http.createServer(({ method, ...rest }: any) => {\n void method;\n const s = rest[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'R4: a declaration const { ...rest } = req', source: handler(RK + `const { ...rest } = req;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R5: a declaration const { method, ...rest } = res', source: handler(RK + `const { method, ...rest } = res as any;\nvoid method;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R6: an assignment ({ ...rest } = req)', source: handler(RK + `let rest: any;\n({ ...rest } = req);\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R7: an assignment ({ method: m, ...rest } = req)', source: handler(RK + `let rest: any;\nlet m: unknown;\n({ method: m, ...rest } = req as any);\nvoid m;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R8: a nested listener parameter (_req, { req: { ...rest } }) (the res.req static-chain twin)', source: H + RK + `http.createServer((_req: any, { req: { ...rest } }: any) => {\n const s = rest[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'R9: a nested declaration const { req: { ...rest } } = res', source: handler(RK + `const { req: { ...rest } } = res as any;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R10: a nested assignment ({ req: { ...rest } } = res)', source: handler(RK + `let rest: any;\n({ req: { ...rest } } = res as any);\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R11: a declaration from the res.req static chain const { ...rest } = res.req', source: handler(RK + `const { ...rest } = (res as any).req;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R12: a rest inside a nested array pattern const { a: [{ ...rest }] } = req', source: handler(RK + `const { a: [{ ...rest }] } = req as any;\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + { form: 'R13: a FunctionDeclaration listener with a rest parameter pattern', source: H + RK + `function handler({ ...rest }: any) {\n const s = rest[rk];\n s.connect(80, 'example.com');\n}\nhttp.createServer(handler);` }, + { form: 'R14: a defaulted nested assignment target ({ req: { ...rest } = {} } = res)', source: handler(RK + `let rest: any;\n({ req: { ...rest } = {} } = res as any);\nconst s = rest[rk];\ns.connect(80, 'example.com');`) }, + ]; + for (const { form, source } of objectRestReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-2 (6, preserve): an object rest off an UNTRACKED receiver is not a req/res delivery. ---- + const objectRestAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a rest off an unrelated object const { ...rest } = cfg', source: `const cfg = { a: 1, b: 2 };\nconst { a, ...rest } = cfg;\nvoid a;\nvoid rest;` }, + { form: 'a rest parameter on an unrelated function', source: `function f({ ...rest }: Record): void {\n void rest;\n}\nvoid f;` }, + { form: 'a rest assignment off an unrelated object', source: `const cfg: Record = {};\nlet rest: unknown;\n({ ...rest } = cfg);\nvoid rest;` }, + { form: 'an ARRAY rest parameter (...args) with a static harmless read', source: H + `http.createServer((...args: any[]) => {\n void args.length;\n});` }, + ]; + for (const { form, source } of objectRestAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- MECH-2 (7): `req.client` — the third IncomingMessage socket-value alias joins the + // socket-value name family (RULE A, receiver-independent like `socket`/`connection`). ---- + const clientReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'C1: a direct req.client member', source: handler(`req.client.connect(80, 'example.com');`) }, + { form: "C2: a static-computed req['client']", source: handler(`void req['client'];`) }, + { form: 'C3: a { client } destructuring off req', source: handler(`const { client } = req as any;\nvoid client;`) }, + { form: 'C4: a renamed { client: c } destructuring off res', source: handler(`const { client: c } = res as any;\nvoid c;`) }, + { form: "C5: a concatenated req['cli' + 'ent']", source: handler(`void req['cli' + 'ent'];`) }, + { form: "C6: a const-bound key const k = 'client'; req[k]", source: handler(`const k = 'client';\nvoid req[k];`) }, + { form: 'C7: an assignment ({ client: c } = req)', source: handler(`let c: unknown;\n({ client: c } = req as any);\nvoid c;`) }, + { form: 'C8: an inline ({ client }) listener parameter', source: H + `http.createServer(({ client }: any) => {\n void client;\n});` }, + { form: 'C9: an unrelated api.client (accepted global false positive, parity with camera.socket)', source: `const api = { client: 1 };\nvoid api.client;` }, + { form: "C10: Reflect.get(req, 'client')", source: handler(`void Reflect.get(req, 'client');`) }, + ]; + for (const { form, source } of clientReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-2 (8): `res.req[k]` — a bounded STATIC member chain rooted at a tracked req/res + // parameter (dotted / optional / static-key element hops, through transparent wrappers) is + // a tracked receiver for RULE A2, so an indeterminate key on `res.req` fails closed. ---- + const staticChainReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'S1: res.req[runtimeKey]', source: handler(RK + `const s = (res as any).req[rk];\ns.connect(80, 'example.com');`) }, + { form: "S2: res['req'][runtimeKey]", source: handler(RK + `const s = (res as any)['req'][rk];\ns.connect(80, 'example.com');`) }, + { form: "S3: res['r' + 'eq'][runtimeKey] (resolved static hop)", source: handler(RK + `const s = (res as any)['r' + 'eq'][rk];\ns.connect(80, 'example.com');`) }, + { form: 'S4: (res.req as any)[runtimeKey] (wrapped chain)', source: handler(RK + `const s = ((res as any).req as any)[rk];\ns.connect(80, 'example.com');`) }, + { form: 'S5: res?.req?.[runtimeKey] (optional chain)', source: handler(RK + `const s = (res as any)?.req?.[rk];\ns.connect(80, 'example.com');`) }, + { form: 'S6: res.req.headers[runtimeKey] (deeper static chain)', source: handler(RK + `void (res as any).req.headers[rk];`) }, + { form: 'S7: a declaration const { [runtimeKey]: s } = res.req', source: handler(RK + `const { [rk]: s } = (res as any).req;\ns.connect(80, 'example.com');`) }, + { form: 'S8: an assignment ({ [runtimeKey]: s } = res.req)', source: handler(RK + `let s: any;\n({ [rk]: s } = (res as any).req);\ns.connect(80, 'example.com');`) }, + { form: 'S9: an assignment ({ socket: s } = res.req) (capability name on the chain receiver)', source: handler(`let s: unknown;\n({ socket: s } = (res as any).req);\nvoid s;`) }, + { form: "S10: a const-key hop const k = 'req'; res[k][runtimeKey]", source: handler(RK + `const k = 'req';\nvoid (res as any)[k][rk];`) }, + { form: 'S11: a non-null chain res!.req![runtimeKey]', source: handler(RK + `void (res as any)!.req![rk];`) }, + { form: 'S12: req.headers[runtimeKey] (any static chain rooted at req fails closed)', source: handler(RK + `void req.headers[rk];`) }, + ]; + for (const { form, source } of staticChainReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-2 (8, preserve): a static harmless key on the chain, and a chain NOT rooted at a + // tracked req/res param, keep their disposition (no alias / value flow). ---- + const staticChainAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a static harmless key on the chain res.req['method']", source: handler(`void (res as any).req['method'];`) }, + { form: 'a dotted harmless chain res.req.method', source: handler(`void (res as any).req.method;`) }, + { form: 'a chain rooted at an unrelated object unrelated.req[runtimeKey]', source: RK + `const unrelated: any = {};\nvoid unrelated.req[rk];` }, + { form: 'a chain broken by a call res.getHeader("x")[runtimeKey] (frozen callee boundary)', source: handler(RK + `void (res as any).getHeader('x')[rk];`) }, + ]; + for (const { form, source } of staticChainAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- MECH-2 (9): STRUCTURAL reflective acquisition — a direct call to the free, unshadowed + // built-in `Reflect.get` / `Reflect.getOwnPropertyDescriptor` / `Object.getOwnPropertyDescriptor` + // whose property-key argument RESOLVES (bounded socket key resolver) to a static + // socket-acquisition name DENIES on any target; an INDETERMINATE key DENIES on a tracked + // req/res target. Recognized by the callee STRUCTURE — never by a call argument's text. ---- + const reflectiveReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: "X1: Reflect.get(req, 'socket')", source: handler(`const s = Reflect.get(req, 'socket');\ns.connect(80, 'example.com');`) }, + { form: "X2: a folded key Reflect.get(req, 'sock' + 'et')", source: handler(`void Reflect.get(req, 'sock' + 'et');`) }, + { form: "X3: a const-bound key const k = 'socket'; Reflect.get(req, k)", source: handler(`const k = 'socket';\nvoid Reflect.get(req, k);`) }, + { form: "X4: Reflect.get(server, 'on') (delivery member on any target)", source: wrapperHead + `const on = Reflect.get(server, 'on');\nvoid on;` }, + { form: "X5: Reflect.getOwnPropertyDescriptor(req, 'socket')", source: handler(`void Reflect.getOwnPropertyDescriptor(req, 'socket');`) }, + { form: "X6: Object.getOwnPropertyDescriptor(req, 'socket')", source: handler(`void Object.getOwnPropertyDescriptor(req, 'socket');`) }, + { form: 'X7: an indeterminate key Reflect.get(req, runtimeKey) on a tracked req', source: handler(RK + `const s = Reflect.get(req, rk);\ns.connect(80, 'example.com');`) }, + { form: 'X8: an indeterminate key Object.getOwnPropertyDescriptor(res, runtimeKey) on a tracked res', source: handler(RK + `void Object.getOwnPropertyDescriptor(res, rk);`) }, + { form: 'X9: an indeterminate key on the res.req static chain Reflect.get(res.req, runtimeKey)', source: handler(RK + `void Reflect.get((res as any).req, rk);`) }, + { form: "X10: a static-element callee Reflect['get'](req, 'socket')", source: handler(`void Reflect['get'](req, 'socket');`) }, + { form: "X11: a folded callee Object['getOwnProperty' + 'Descriptor'](req, 'socket')", source: handler(`void Object['getOwnProperty' + 'Descriptor'](req, 'socket');`) }, + { form: "X12: a template key Reflect.get(req, `connection`)", source: handler('void Reflect.get(req, `connection`);') }, + { form: "X13: an unrelated target Reflect.get(camera, 'socket') (static name, any target)", source: `const camera = { socket: 1 };\nvoid Reflect.get(camera, 'socket');` }, + { form: 'X14: a spread argument list Reflect.get(...pair) (unsupported shape at the reflective boundary)', source: handler(`const pair = [req, 'socket'] as const;\nvoid Reflect.get(...(pair as any));`) }, + { form: "X15: an optional-call Reflect.get?.(req, 'socket')", source: handler(`void Reflect.get?.(req, 'socket');`) }, + { form: "X16: Reflect.get(req, 'setTimeout') (delivery member)", source: handler(`void Reflect.get(req, 'setTimeout');`) }, + ]; + for (const { form, source } of reflectiveReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-2 (9, preserve): the rejected broad "any string argument" rule is NOT implemented — + // only the STRUCTURAL free-builtin reflective read is recognized. ---- + const reflectiveAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "Reflect.get(local, 'fetch') (non-socket name, non-global receiver)", source: `const local = { fetch() {} };\nReflect.get(local, 'fetch')();` }, + { form: "Reflect.get(globalThis, 'crypto') (non-socket, non-network name)", source: `void Reflect.get(globalThis, 'crypto');` }, + { form: "a user-shadowed Reflect.get(req, 'socket') (ordinary object, not the built-in)", source: handler(`const Reflect = { get(_t: unknown, _k: string): unknown { return 1; } };\nvoid Reflect.get(req, 'socket');`) }, + { form: "a user-shadowed Object.getOwnPropertyDescriptor(req, 'socket')", source: handler(`const Object = { getOwnPropertyDescriptor(_t: unknown, _k: string): unknown { return 1; } };\nvoid Object.getOwnPropertyDescriptor(req, 'socket');`) }, + { form: "res.setHeader('connection', 'close') (an ordinary method call, not a reflective read)", source: handler(`res.setHeader('connection', 'close');`) }, + { form: "map.get('connection') (a non-builtin .get)", source: `const map = new Map();\nvoid map.get('connection');` }, + { form: "log('socket') (a plain call with a socket-named string)", source: `declare function log(s: string): void;\nlog('socket');` }, + { form: "Reflect.get(req, 'method') (harmless resolved key)", source: handler(`void Reflect.get(req, 'method');`) }, + { form: 'Reflect.get(unrelated, runtimeKey) (indeterminate key on an UNTRACKED target)', source: RK + `const unrelated: Record = {};\nvoid Reflect.get(unrelated, rk);` }, + { form: "Reflect.has(req, 'socket') (not an acquisition API)", source: handler(`void Reflect.has(req, 'socket');`) }, + { form: "Reflect.ownKeys(req) (no property-key argument)", source: handler(`void Reflect.ownKeys(req);`) }, + ]; + for (const { form, source } of reflectiveAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- MECH-1/2 negative controls (preserve): the existing controls asserted elsewhere in this + // suite (setter-only / concise-method reserved options, `IncomingMessageLimit`, unrelated + // `foo.createServer`, a reserved key outside createServer, the five real host files) are + // untouched; the `__proto__`-specific controls are pinned here. ---- + const protoControlAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "a computed ['__proto__'] key (an ordinary own property, never the prototype)", source: H + `class X {}\nhttp.createServer({ ['__proto__']: { IncomingMessage: X } } as any, () => {});` }, + { form: 'a shorthand { __proto__ } key (an ordinary own property, never the prototype)', source: H + `class X {}\nconst __proto__ = { IncomingMessage: X };\nhttp.createServer({ __proto__ } as any, () => {});` }, + { form: 'a __proto__ concise METHOD { __proto__() {} } (an own method, never the prototype)', source: H + `http.createServer({ __proto__() { return 1; } } as any, () => {});` }, + { form: 'a __proto__ GETTER { get __proto__() {} } (an own accessor, never the prototype)', source: H + `http.createServer({ get __proto__() { return 1; } } as any, () => {});` }, + { form: 'a __proto__ data key OUTSIDE any createServer call', source: H + `class X {}\nconst options = { __proto__: { IncomingMessage: X } };\nvoid options;\nhttp.createServer(() => {});` }, + { form: 'a __proto__ data key on an unrelated foo.createServer', source: `class X {}\nconst foo = { createServer(_o: unknown): void {} };\nfoo.createServer({ __proto__: { IncomingMessage: X } });` }, + ]; + for (const { form, source } of protoControlAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- ADVERSARIAL CLOSURE AUDIT (siblings INSIDE the authorized finite grammar, pinned) ---- + const closureSiblingReject: readonly { readonly form: string; readonly source: string }[] = [ + // MECH-1 — every supported createServer callee spine reaches the same boundary. + { form: 'A1: a named-import new createServer(letListener)', source: `import { createServer } from 'node:http';\n` + RK + `let handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nvoid new createServer(handler);` }, + { form: 'A2: a destructured const { createServer: cs } = http; cs(letListener)', source: H + RK + `const { createServer: cs } = http;\nlet handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\ncs(handler);` }, + { form: "A3: a static-element callee http['createServer'](letListener)", source: H + RK + `let handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp['createServer'](handler);` }, + // MECH-1 — other non-function / non-literal argument shapes. + { form: 'A4: a class declaration supplied as the listener', source: H + `class L {}\nhttp.createServer(L as any);` }, + { form: 'A5: a const class expression supplied as the listener', source: H + `const L = class {};\nhttp.createServer(L as any);` }, + { form: 'A6: a comma-expression listener (0, handler)', source: H + RK + `const handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer((0, handler));` }, + { form: 'A7: an async arrow listener still tracks its parameter (function-like parity)', source: H + RK + `http.createServer(async (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'A8: a generator function-expression listener still tracks its parameter', source: H + RK + `http.createServer(function* (req: any) {\n const s = req[rk];\n s.connect(80, 'example.com');\n} as any);` }, + { form: 'A9: a type-assertion-wrapped let listener handler', source: H + RK + `let handler = (req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: 'A10: a tagged-template options argument', source: H + `declare function tag(s: TemplateStringsArray): any;\nhttp.createServer(tag\`x\`, () => {});` }, + // MECH-1 — every ObjectLiteralElementLike kind inside the options literal. + { form: 'A11: a reserved key beside a spread { IncomingMessage: X, ...rest }', source: H + `class X {}\nconst rest = {};\nhttp.createServer({ IncomingMessage: X, ...rest } as any, () => {});` }, + { form: 'A12: a getter + setter pair { get IncomingMessage() {}, set IncomingMessage(v) {} }', source: H + `class X {}\nhttp.createServer({ get IncomingMessage() { return X; }, set IncomingMessage(_v: any) {} } as any, () => {});` }, + { form: "A13: a computed getter resolving to __proto__ is NOT the prototype, but { ['Incoming' + 'Message']: X } beside it is reserved", source: H + `class X {}\nhttp.createServer({ get ['__proto__']() { return 1; }, ['Incoming' + 'Message']: X } as any, () => {});` }, + { form: 'A14: a template-substituted __proto__ data key is a computed own property, but a substituted reserved key is caught', source: H + `class X {}\nhttp.createServer({ [\`__pro\${'to__'}\`]: {}, [\`Server\${'Response'}\`]: X } as any, () => {});` }, + { form: 'A15: a reserved option in the SECOND-argument literal (every argument is dispositioned)', source: H + `class X {}\nhttp.createServer(() => {}, { IncomingMessage: X } as any);` }, + // MECH-2 — object-rest / chain / reflective siblings. + { form: 'A16: an object rest assigned into a member target ({ ...holder.rest } = req)', source: handler(`const holder: any = {};\n({ ...holder.rest } = req as any);`) }, + { form: 'A17: a computed-key nested rest ({ [k]: { ...rest } } = req) fails closed on the key', source: handler(RK + `let rest: any;\n({ [rk]: { ...rest } } = req as any);\nvoid rest;`) }, + { form: 'A18: a rest parameter pattern on a const arrow listener', source: H + RK + `const handler = ({ ...rest }: any) => {\n const s = rest[rk];\n s.connect(80, 'example.com');\n};\nhttp.createServer(handler);` }, + { form: "A19: a parenthesized reflective callee (Reflect).get(req, 'socket')", source: handler(`void (Reflect).get(req, 'socket');`) }, + { form: "A20: a parenthesized reflective member (Reflect.get)(req, 'socket')", source: handler(`void (Reflect.get)(req, 'socket');`) }, + { form: "A21: Reflect.get(req, 'socket', receiver) with a third argument", source: handler(`void Reflect.get(req, 'socket', {});`) }, + { form: 'A22: Reflect.get(req, 0) — a numeric (indeterminate) key on a tracked req fails closed', source: handler(`void Reflect.get(req, 0 as any);`) }, + { form: "A23: Reflect.getOwnPropertyDescriptor(server, 'on') (delivery member, any target)", source: wrapperHead + `void Reflect.getOwnPropertyDescriptor(server, 'on');` }, + { form: 'A24: an indeterminate chain hop inside a longer chain res[k1][k2] is caught at the inner access', source: handler(RK + `declare const other: string;\nvoid (res as any)[rk][other];`) }, + { form: 'A25: arguments read through a nested object method inside the listener', source: H + RK + `http.createServer(function () {\n const helper = {\n grab() {\n return arguments[0];\n },\n };\n void helper;\n});` }, + ]; + for (const { form, source } of closureSiblingReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- OUTSIDE_BOUNDARY (frozen — deliberately NOT closed by this repair; asserted so the + // mechanism is provably not broadened into alias / value-flow analysis). ---- + const frozenOutsideBoundaryAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'the alias + runtime key const r = req; r[k] (frozen)', source: handler(RK + `const r = req;\nvoid r[rk];`) }, + { form: 'the array-wrapped [req][0][k] (frozen)', source: handler(RK + `void [req][0][rk];`) }, + { form: 'the spread-copy ({ ...req })[k] (frozen)', source: handler(RK + `void ({ ...req } as any)[rk];`) }, + { form: 'Object.values(req) (frozen)', source: handler(`void Object.values(req);`) }, + { form: 'Object.entries(req) (frozen)', source: handler(`void Object.entries(req);`) }, + { form: 'Object.assign({}, req) (frozen)', source: handler(`void Object.assign({}, req);`) }, + { form: 'Object.getOwnPropertyDescriptors(req) (frozen — not the recognized single-key API)', source: handler(`void Object.getOwnPropertyDescriptors(req);`) }, + { form: "an aliased Reflect const R = Reflect; R.get(req, 'socket') (frozen)", source: handler(`const R = Reflect;\nvoid R.get(req, 'socket');`) }, + { form: "a global-object member spelling globalThis.Reflect.get(req, 'socket') (frozen — the aliased-Reflect indirection class, same boundary as NET F1)", source: handler(`void (globalThis as any).Reflect.get(req, 'socket');`) }, + { form: "a call-chained Reflect.get.call(Reflect, req, 'socket') (frozen — call/apply/bind indirection)", source: handler(`void Reflect.get.call(Reflect, req, 'socket');`) }, + { form: 'a renamed destructuring alias ({ req: r }) => r[k] (frozen — the req/res alias family)', source: H + RK + `http.createServer((_req: any, { req: r }: any) => {\n void r[rk];\n});` }, + { form: 'the runtime-computed server[String(4317)] (frozen)', source: wrapperHead + `void server[String(4317)];` }, + ]; + for (const { form, source } of frozenOutsideBoundaryAllow) { + it(`allows (frozen, outside the boundary) ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ===================================================================================== + // DAY-7 F1 — AMBIENT / BODILESS listener source (MECH-1 disposition). `resolveCreateServerArgument` + // resolves a unique FunctionDeclaration (or a unique-const initializer) to its declaration node, + // and the disposition previously accepted ANY function-like node as the supported listener — an + // ambient `declare function listener(req)` / `export declare function` / `declare global` member / + // `declare const h = …` initializer and a bodiless overload signature were therefore treated as + // SUPPORTED although they emit NO runtime value in this file: the emitted `createServer(listener)` + // binds to whatever the global environment supplies, so the tracked parameters are not the + // parameters of the code that will run. The disposition now rejects a resolved source that is in an + // ambient context (`isInAmbientContext`) or is a FunctionDeclaration with `body === undefined` — + // the same fail-closed unsupported-shape branch, decided syntactically (no body analysis). + // ===================================================================================== + const ambientListenerReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'F1-1: the reported ambient `declare function listener(req)` supplied to createServer', source: H + `declare function listener(req: any): void;\nhttp.createServer(listener);` }, + { form: 'F1-2: an `export declare function listener(req)`', source: H + `export declare function listener(req: any): void;\nhttp.createServer(listener);` }, + { form: 'F1-3: a bodiless overload signature with no implementation', source: H + `function listener(req: any): void;\nhttp.createServer(listener);` }, + { form: 'F1-4: a const alias resolving to the ambient declaration', source: H + `declare function listener(req: any): void;\nconst h = listener;\nhttp.createServer(h);` }, + { form: 'F1-5: a bounded const chain resolving to the ambient declaration', source: H + `declare function listener(req: any): void;\nconst a = listener;\nconst b = a;\nhttp.createServer(b);` }, + { form: 'F1-6: an as-wrapped ambient identifier (transparent wrapper)', source: H + `declare function listener(req: any): void;\nhttp.createServer(listener as any);` }, + { form: 'F1-7: a parenthesized / non-null-wrapped ambient identifier', source: H + `declare function listener(req: any): void;\nhttp.createServer((listener)!);` }, + { form: 'F1-8: a `declare global { function listener }` member', source: H + `declare global {\n function listener(req: any): void;\n}\nhttp.createServer(listener);` }, + { form: 'F1-9: a `declare const h = arrow` ambient initializer (resolver reaches the arrow)', source: H + `declare const h: any = (req: any) => {\n void req;\n};\nhttp.createServer(h);` }, + { form: 'F1-10: a `declare const h = function` ambient initializer', source: H + `declare const h = function (req: any): void {\n void req;\n};\nhttp.createServer(h);` }, + { form: 'F1-11: a `declare function` that carries a body is still ambient (emits nothing)', source: H + `declare function listener(req: any): void {}\nhttp.createServer(listener);` }, + { form: 'F1-12: an ambient listener on the `new http.createServer(…)` boundary', source: H + `declare function listener(req: any): void;\nvoid new http.createServer(listener);` }, + { form: 'F1-13: an ambient listener supplied as the SECOND argument', source: H + `declare function listener(req: any): void;\nhttp.createServer({ keepAlive: true }, listener);` }, + { form: 'F1-14: an ambient `declare const options = {…}` literal (same ambient-source rule, options side)', source: H + `declare const options = { maxHeaderSize: 1 };\nhttp.createServer(options, () => {});` }, + { form: 'F1-15: an ambient listener reached through a named createServer import', source: `import { createServer } from 'node:http';\ndeclare function listener(req: any): void;\ncreateServer(listener);` }, + ]; + for (const { form, source } of ambientListenerReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F1 (preserve): every previously supported EXECUTABLE listener shape keeps its disposition. ---- + const ambientListenerAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an ordinary FunctionDeclaration WITH a body', source: H + `function listener(req: any, res: any): void {\n res.end(req.method);\n}\nhttp.createServer(listener);` }, + { form: 'an ordinary const function expression', source: H + `const listener = function (req: any, res: any): void {\n res.end(req.method);\n};\nhttp.createServer(listener);` }, + { form: 'an ordinary inline arrow listener', source: H + `http.createServer((req: any, res: any) => {\n res.end(req.method);\n});` }, + { form: 'a bounded const chain to a bodied FunctionDeclaration', source: H + `function listener(req: any, res: any): void {\n res.end('ok');\n}\nconst a = listener;\nconst b = a;\nhttp.createServer(b);` }, + { form: 'an overload pair whose implementation has a body (two declarations: resolver stops, executable code still inspected)', source: H + `function listener(req: any, res: any): void;\nfunction listener(req: any, res: any): void {\n res.end('ok');\n}\nvoid listener;\nhttp.createServer((req: any, res: any) => {\n res.end(req.method);\n});` }, + { form: 'an unrelated ambient declaration beside a bodied listener', source: H + `declare function other(): void;\nfunction listener(req: any, res: any): void {\n res.end('ok');\n}\nhttp.createServer(listener);\nvoid other;` }, + { form: 'an ambient function supplied to an UNRELATED foo.createServer (not the privileged boundary)', source: `declare function listener(req: any): void;\nconst foo = { createServer(_h: unknown): void {} };\nfoo.createServer(listener);` }, + ]; + for (const { form, source } of ambientListenerAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ===================================================================================== + // DAY-7 F2 — CONSTRUCTOR RE-DERIVATION (RULE A family iii). The createServer call RESULT exposes + // the privileged Server constructor as its ordinary `constructor` property, so + // `new (http.createServer() as any).constructor(listener)` builds a server whose arguments never + // pass the createServer boundary, and `Object.getPrototypeOf(server.constructor)` reaches + // `net.Server`. `constructor` joins the SAME receiver-independent static-name family as the delivery + // members: dotted, static / binder-resolved element key, declaration destructuring, ASSIGNMENT + // destructuring (receiver-independent parity), and the structural reflective read. RC rule (a) + // already rejects the dotted / static-string spelling by TEXT; NET now rejects the whole grammar by + // binder identity, so a same-text shadow, a template substitution, a destructuring, or a + // `Reflect.get(server, 'constructor')` cannot launder it. + // ===================================================================================== + const constructorRederivationReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'F2-1: the reported new (http.createServer() as any).constructor(listener) re-derivation', source: H + RK + `void new (http.createServer() as any).constructor((req: any) => {\n const s = req[rk];\n s.connect(80, 'example.com');\n});` }, + { form: 'F2-2: a dotted server.constructor', source: wrapperHead + `void server.constructor;` }, + { form: 'F2-3: an optional-chained server?.constructor', source: wrapperHead + `void server?.constructor;` }, + { form: "F2-4: a static-element server['constructor']", source: wrapperHead + `void server['constructor'];` }, + { form: 'F2-5: a template-element server[`constructor`]', source: wrapperHead + 'void server[`constructor`];' }, + { form: "F2-6: a concatenated server['construc' + 'tor']", source: wrapperHead + `void server['construc' + 'tor'];` }, + { form: 'F2-7: a substituted-template server[`construc${\'tor\'}`] (binder fold; RC text fold misses it)', source: wrapperHead + 'void server[`construc${\'tor\'}`];' }, + { form: "F2-8: a const-bound key const key = 'constructor'; server[key]", source: wrapperHead + `const key = 'constructor';\nvoid server[key];` }, + { form: 'F2-9: the const-bound key under an unrelated same-text shadow in another scope (binder identity; RC text fold misses it)', source: wrapperHead + `function other(): void {\n const key = 'x';\n void key;\n}\nconst key = 'constructor';\nvoid server[key];\nvoid other;` }, + { form: 'F2-10: a declaration destructuring const { constructor: S } = server', source: wrapperHead + `const { constructor: S } = server as any;\nvoid S;` }, + { form: 'F2-11: a shorthand declaration destructuring const { constructor } = server', source: wrapperHead + `const { constructor } = server as any;\nvoid constructor;` }, + { form: "F2-12: a computed declaration destructuring const { ['construc' + 'tor']: S } = server", source: wrapperHead + `const { ['construc' + 'tor']: S } = server as any;\nvoid S;` }, + { form: 'F2-13: a nested declaration destructuring const { a: { constructor: S } } = wrap', source: wrapperHead + `const wrap = { a: server };\nconst { a: { constructor: S } } = wrap as any;\nvoid S;` }, + { form: 'F2-14: an assignment destructuring ({ constructor: S } = server)', source: wrapperHead + `let S: any;\n({ constructor: S } = server as any);\nvoid S;` }, + { form: 'F2-15: a shorthand assignment destructuring ({ constructor } = server)', source: wrapperHead + `let constructor: any;\n({ constructor } = server as any);\nvoid constructor;` }, + { form: "F2-16: a computed assignment destructuring ({ ['construc' + 'tor']: S } = server)", source: wrapperHead + `let S: any;\n({ ['construc' + 'tor']: S } = server as any);\nvoid S;` }, + { form: 'F2-17: a nested assignment destructuring ({ a: { constructor: S } } = wrap)', source: wrapperHead + `const wrap = { a: server };\nlet S: any;\n({ a: { constructor: S } } = wrap as any);\nvoid S;` }, + { form: "F2-18: a reflective Reflect.get(server, 'constructor')", source: wrapperHead + `void Reflect.get(server, 'constructor');` }, + { form: "F2-19: a reflective Reflect.get(server, 'construc' + 'tor') (computed-static key)", source: wrapperHead + `void Reflect.get(server, 'construc' + 'tor');` }, + { form: "F2-20: a reflective Reflect.getOwnPropertyDescriptor(proto, 'constructor') on the prototype", source: wrapperHead + `void Reflect.getOwnPropertyDescriptor(Object.getPrototypeOf(server), 'constructor');` }, + { form: "F2-21: a reflective Object.getOwnPropertyDescriptor(proto, 'constructor') on the prototype", source: wrapperHead + `void Object.getOwnPropertyDescriptor(Object.getPrototypeOf(server), 'constructor');` }, + { form: 'F2-22: the direct http.createServer().constructor chain', source: H + `void http.createServer().constructor;` }, + { form: "F2-23: a wrapped ((server) as any)['constructor']", source: wrapperHead + `void ((server) as any)['constructor'];` }, + { form: 'F2-24: Object.getPrototypeOf(server.constructor) — the net.Server path needs the name', source: wrapperHead + `void Object.getPrototypeOf(server.constructor);` }, + { form: 'F2-25: a parameter destructuring function f({ constructor }) (receiver-independent binding key)', source: `function f({ constructor }: any): void {\n void constructor;\n}\nvoid f;` }, + { form: 'F2-26: a class extends clause reading server.constructor', source: wrapperHead + `class S extends (server.constructor as any) {}\nvoid S;` }, + ]; + for (const { form, source } of constructorRederivationReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F2 (preserve): a constructor DECLARATION, an object-literal DATA key, string data, a + // superstring, a type-position member, and ordinary host usage are not the acquisition name at a + // member / binding / assignment-key position. ---- + const constructorRederivationAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a class constructor DECLARATION and ordinary new', source: `class X {\n constructor(private readonly v: number) {}\n}\nvoid new X(1);` }, + { form: "string data 'constructor'", source: `const s = 'constructor';\nvoid s;` }, + { form: 'a superstring member constructorName', source: `const o = { constructorName: 'x' };\nvoid o.constructorName;` }, + { form: 'an object-literal DATA key { constructor: 1 } (not a destructuring target)', source: `const o = { constructor: 1 };\nvoid o;` }, + { form: 'a type-literal member named constructor', source: `type T = { constructor: number };\ndeclare const t: T;\nvoid t;` }, + { form: 'the ordinary host-like server.listen(…) usage', source: wrapperHead + `server.listen(4317, '127.0.0.1');` }, + ]; + for (const { form, source } of constructorRederivationAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ===================================================================================== + // DAY-7 MECH-1 — CONST-SPINE CONFINEMENT of a resolved options literal. A `const` freezes the + // binding, not the object: the bounded argument resolver normalized `const o = {}; …; + // http.createServer(o, …)` to the literal `{}` while Node reads the object as it is AT CALL TIME — + // after any property / element / computed / const-key / runtime-key assignment, `Object.assign`, + // `Object.defineProperty`, `Object.setPrototypeOf`, `Reflect.set`, `o.__proto__ = …`, a mutation + // through an alias or alias chain, inside a function / closure, or textually after the call. Static + // object immutability cannot be proven inside the bounded mechanism, so the disposition is + // POSITIONAL and fail-closed: a spine-resolved literal is supported ONLY when every spine binding is + // referenced nowhere except the spine's own initializer chain and proven createServer argument + // positions, and no spine binding is exported. Any other occurrence — mutation, benign read, side + // alias, call argument, container, object value, return, export, closure capture, type query — is an + // identity escape and DENIES. No value flow / alias graph: references are inventoried once by binder + // symbol and classified by a constant-depth parent walk. + // ===================================================================================== + const OPTS_CAPTURE = `class Capture {\n constructor(s: any) {\n s.connect(80, 'example.com');\n }\n}\n`; + const mutatedOptionsReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'M1: the reported direct property mutation o.IncomingMessage = Capture', source: H + OPTS_CAPTURE + `const o: any = {};\no.IncomingMessage = Capture;\nhttp.createServer(o, () => {});` }, + { form: "M2: a static element mutation o['IncomingMessage'] = Capture", source: H + OPTS_CAPTURE + `const o: any = { maxHeaderSize: 1 };\no['IncomingMessage'] = Capture;\nhttp.createServer(o, () => {});` }, + { form: "M3: a computed-static mutation o['Incoming' + 'Message'] = Capture", source: H + OPTS_CAPTURE + `const o: any = {};\no['Incoming' + 'Message'] = Capture;\nhttp.createServer(o, () => {});` }, + { form: "M4: a binder-resolved const-key mutation const k = 'IncomingMessage'; o[k] = Capture", source: H + OPTS_CAPTURE + `const o: any = {};\nconst k = 'IncomingMessage';\no[k] = Capture;\nhttp.createServer(o, () => {});` }, + { form: 'M5: a runtime-key mutation o[rk] = Capture', source: H + OPTS_CAPTURE + RK + `const o: any = {};\no[rk] = Capture;\nhttp.createServer(o, () => {});` }, + { form: 'M6: Object.assign(o, { IncomingMessage: Capture })', source: H + OPTS_CAPTURE + `const o = {};\nObject.assign(o, { IncomingMessage: Capture });\nhttp.createServer(o as any, () => {});` }, + { form: "M7: Object.defineProperty(o, 'IncomingMessage', { value: Capture })", source: H + OPTS_CAPTURE + `const o = {};\nObject.defineProperty(o, 'IncomingMessage', { value: Capture });\nhttp.createServer(o as any, () => {});` }, + { form: 'M8: Object.setPrototypeOf(o, { IncomingMessage: Capture })', source: H + OPTS_CAPTURE + `const o = {};\nObject.setPrototypeOf(o, { IncomingMessage: Capture });\nhttp.createServer(o as any, () => {});` }, + { form: 'M9: an o.__proto__ = { IncomingMessage: Capture } assignment', source: H + OPTS_CAPTURE + `const o: any = {};\no.__proto__ = { IncomingMessage: Capture };\nhttp.createServer(o, () => {});` }, + { form: "M10: Reflect.set(o, 'IncomingMessage', Capture)", source: H + OPTS_CAPTURE + `const o = {};\nReflect.set(o, 'IncomingMessage', Capture);\nhttp.createServer(o as any, () => {});` }, + { form: 'M11: a mutation through a const alias (const a = o; a.IncomingMessage = Capture)', source: H + OPTS_CAPTURE + `const o: any = {};\nconst a = o;\na.IncomingMessage = Capture;\nhttp.createServer(o, () => {});` }, + { form: 'M12: a mutation through a bounded const-alias chain (a = o, b = a, b.X = …)', source: H + OPTS_CAPTURE + `const o: any = {};\nconst a = o;\nconst b = a;\nb.IncomingMessage = Capture;\nhttp.createServer(o, () => {});` }, + { form: 'M13: the spine alias at the boundary while the root is mutated (const b = o; o.X = …; createServer(b))', source: H + OPTS_CAPTURE + `const o: any = {};\nconst b = o;\no.IncomingMessage = Capture;\nhttp.createServer(b, () => {});` }, + { form: 'M14: a side alias off the spine that mutates (a = {}, b = a, c = a, c.X = …; createServer(b))', source: H + OPTS_CAPTURE + `const a: any = {};\nconst b = a;\nconst c = a;\nc.IncomingMessage = Capture;\nhttp.createServer(b, () => {});` }, + { form: 'M15: a mutation textually AFTER the createServer call (order not statically provable)', source: H + OPTS_CAPTURE + `const o: any = {};\nhttp.createServer(o, () => {});\no.IncomingMessage = Capture;` }, + { form: 'M16: a mutation inside a function body', source: H + OPTS_CAPTURE + `const o: any = {};\nfunction poison(): void {\n o.IncomingMessage = Capture;\n}\npoison();\nhttp.createServer(o, () => {});` }, + { form: 'M17: a closure capture that mutates', source: H + OPTS_CAPTURE + `const o: any = {};\nconst later = () => {\n o.IncomingMessage = Capture;\n};\nvoid later;\nhttp.createServer(o, () => {});` }, + { form: 'M18: an escape to an unknown call f(o)', source: H + OPTS_CAPTURE + `declare function f(x: unknown): void;\nconst o = {};\nf(o);\nhttp.createServer(o as any, () => {});` }, + { form: 'M19: an escape into a container [o]', source: H + OPTS_CAPTURE + `const o = {};\nconst holder = [o];\nvoid holder;\nhttp.createServer(o as any, () => {});` }, + { form: 'M20: an escape into a shorthand object value { o }', source: H + OPTS_CAPTURE + `const o = {};\nconst holder = { o };\nvoid holder;\nhttp.createServer(o as any, () => {});` }, + { form: 'M21: an escape via return', source: H + OPTS_CAPTURE + `const o = {};\nfunction get(): unknown {\n return o;\n}\nvoid get;\nhttp.createServer(o as any, () => {});` }, + { form: 'M22: an exported declaration export const o = {}', source: H + OPTS_CAPTURE + `export const o = {};\nhttp.createServer(o as any, () => {});` }, + { form: 'M23: an export specifier export { o }', source: H + OPTS_CAPTURE + `const o = {};\nexport { o };\nhttp.createServer(o as any, () => {});` }, + { form: 'M24: an export default o', source: H + OPTS_CAPTURE + `const o = {};\nexport default o;\nhttp.createServer(o as any, () => {});` }, + { form: 'M25: a mutation through a shorthand-wrapped alias ({ o }).o.X = …', source: H + OPTS_CAPTURE + `const o: any = {};\n({ o }).o.IncomingMessage = Capture;\nhttp.createServer(o, () => {});` }, + { form: 'M26: a mutation through a parenthesized / as-wrapped reference', source: H + OPTS_CAPTURE + `const o = {};\n((o as any)).IncomingMessage = Capture;\nhttp.createServer(o as any, () => {});` }, + { form: 'M27: a destructuring-assignment target that writes into the object', source: H + OPTS_CAPTURE + `const o: any = {};\n({ a: o.IncomingMessage } = { a: Capture });\nhttp.createServer(o, () => {});` }, + { form: 'M28: a benign-looking READ of the binding (conservative: any other reference is an escape)', source: H + `const o = { maxHeaderSize: 1 };\nvoid o.maxHeaderSize;\nhttp.createServer(o, () => {});` }, + { form: 'M29: the binding supplied to an UNRELATED foo.createServer (not a boundary position)', source: H + OPTS_CAPTURE + `const o: any = {};\nconst foo = {\n createServer(x: any): void {\n x.IncomingMessage = Capture;\n },\n};\nfoo.createServer(o);\nhttp.createServer(o, () => {});` }, + { form: 'M30: the new-form boundary with a mutated const', source: H + OPTS_CAPTURE + `const o: any = {};\no.IncomingMessage = Capture;\nvoid new http.createServer(o, () => {});` }, + { form: 'M31: a type query typeof o (conservative reference)', source: H + `const o = { maxHeaderSize: 1 };\ntype T = typeof o;\nexport type { T };\nhttp.createServer(o, () => {});` }, + { form: 'M32: a for-of / block-scoped re-declaration that shadows the spine name is a different symbol, but the outer binding mutated still denies', source: H + OPTS_CAPTURE + `const o: any = {};\n{\n const o = 1;\n void o;\n}\no.IncomingMessage = Capture;\nhttp.createServer(o, () => {});` }, + ]; + for (const { form, source } of mutatedOptionsReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 const-spine confinement (preserve): a genuinely confined const spine keeps every + // existing supported disposition; an inline literal has no binding to escape; a listener const is + // not subject to the rule (a function's code cannot be replaced through its object). ---- + const confinedOptionsAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a direct inline object literal', source: H + `http.createServer({ maxHeaderSize: 8192 }, () => {});` }, + { form: 'the existing supported const alias used ONLY at the boundary', source: H + `const options = { maxHeaderSize: 8192 };\nhttp.createServer(options, () => {});` }, + { form: 'a const→const spine used only at the boundary', source: H + `const base = { maxHeaderSize: 8192 };\nconst options = base;\nhttp.createServer(options, () => {});` }, + { form: 'a const alias reused at TWO createServer boundaries', source: H + `const options = { maxHeaderSize: 8192 };\nhttp.createServer(options, () => {});\nhttp.createServer(options, () => {});` }, + { form: 'an as-wrapped const alias at the boundary', source: H + `const options = { maxHeaderSize: 8192 };\nhttp.createServer(options as any, () => {});` }, + { form: 'the new-form boundary with an unmutated const alias', source: H + `const options = { maxHeaderSize: 8192 };\nvoid new http.createServer(options, () => {});` }, + { form: 'a benign getter key in a confined const alias', source: H + `const options = { get maxHeaderSize() { return 8192; } };\nhttp.createServer(options as any, () => {});` }, + { form: 'a setter-only reserved key in a confined const alias (existing disposition)', source: H + `const options = { set IncomingMessage(_v: any) {} };\nhttp.createServer(options as any, () => {});` }, + { form: 'a superstring key in a confined const alias', source: H + `const options = { IncomingMessageLimit: 10 };\nhttp.createServer(options as any, () => {});` }, + { form: 'a same-text binding in ANOTHER scope that is mutated (different binder symbol)', source: H + `const options = { maxHeaderSize: 8192 };\nfunction other(): void {\n const options: any = {};\n options.IncomingMessage = 1;\n}\nvoid other;\nhttp.createServer(options, () => {});` }, + { form: 'an object-literal KEY spelled like the binding is not a reference', source: H + `const options = { maxHeaderSize: 8192 };\nconst other = { options: 1 };\nvoid other.options;\nhttp.createServer(options, () => {});` }, + { form: 'a property NAME spelled like the binding is not a reference', source: H + `const options = { maxHeaderSize: 8192 };\ndeclare const holder: { options: number };\nvoid holder.options;\nhttp.createServer(options, () => {});` }, + { form: 'the real host shape: inline listener, no options', source: H + `http.createServer((req: any, res: any) => {\n res.end(req.method);\n});` }, + { form: 'a listener const with other references stays supported (functions are not mutable delivery sources)', source: H + `const handler = (req: any, res: any) => {\n res.end(req.method);\n};\nvoid handler;\nhttp.createServer(handler);` }, + ]; + for (const { form, source } of confinedOptionsAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ===================================================================================== + // DAY-7 MECH-1 — PROTOTYPE-INHERITANCE closure (RULE A family iv). A supported options literal + // inherits from `Object.prototype`; Node's ordinary GET of `options.IncomingMessage` / + // `options.ServerResponse` walks that chain, so polluting `Object.prototype` (own value, inherited + // getter, `Object.assign` / `defineProperty` / `defineProperties`, `Reflect.set`) makes a clean `{}` or + // a confined const alias deliver the live socket (Node 24 verified; the no-options form is immune + // because Node substitutes a frozen null-prototype object). Every statically spelled path to a + // prototype names `prototype`, `__proto__`, or `getPrototypeOf`, so the three names join the SAME + // receiver-independent static-name reservation as `socket` / `on` / `constructor` — member, element + // (binder-resolved), declaration destructuring, assignment destructuring, and reflective read. + // The pollution SITE itself is rejected in whichever host file it appears, which is what makes the + // rule sound across the scanned host tree (a boundary-local check would not be). No prototype graph. + // ===================================================================================== + const prototypePollutionReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'PP1: the reported Object.prototype.IncomingMessage pollution + inline {} options', source: H + OPTS_CAPTURE + `(Object.prototype as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP2: Object.prototype.ServerResponse pollution', source: H + OPTS_CAPTURE + `(Object.prototype as any).ServerResponse = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP3: ({}).__proto__.IncomingMessage pollution', source: H + OPTS_CAPTURE + `({} as any).__proto__.IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP4: Object.assign(Object.prototype, { IncomingMessage })', source: H + OPTS_CAPTURE + `Object.assign(Object.prototype, { IncomingMessage: Capture });\nhttp.createServer({}, () => {});` }, + { form: "PP5: Object.defineProperty(Object.prototype, 'IncomingMessage', { value })", source: H + OPTS_CAPTURE + `Object.defineProperty(Object.prototype, 'IncomingMessage', { value: Capture });\nhttp.createServer({}, () => {});` }, + { form: "PP6: an inherited GETTER Object.defineProperty(Object.prototype, 'IncomingMessage', { get })", source: H + OPTS_CAPTURE + `Object.defineProperty(Object.prototype, 'IncomingMessage', { get: () => Capture });\nhttp.createServer({}, () => {});` }, + { form: 'PP7: Object.defineProperties(Object.prototype, {...})', source: H + OPTS_CAPTURE + `Object.defineProperties(Object.prototype, { IncomingMessage: { value: Capture } });\nhttp.createServer({}, () => {});` }, + { form: "PP8: Reflect.set(Object.prototype, 'IncomingMessage', Capture)", source: H + OPTS_CAPTURE + `Reflect.set(Object.prototype, 'IncomingMessage', Capture);\nhttp.createServer({}, () => {});` }, + { form: 'PP9: Object.getPrototypeOf({}).IncomingMessage = Capture (prototype reached without spelling `prototype`)', source: H + OPTS_CAPTURE + `(Object.getPrototypeOf({}) as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP10: Reflect.getPrototypeOf({}).IncomingMessage = Capture', source: H + OPTS_CAPTURE + `(Reflect.getPrototypeOf({}) as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: "PP11: a static element Object['prototype']", source: H + OPTS_CAPTURE + `(Object as any)['prototype'].IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: "PP12: a concatenated Object['proto' + 'type']", source: H + OPTS_CAPTURE + `(Object as any)['proto' + 'type'].IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP13: a template element Object[`prototype`]', source: H + OPTS_CAPTURE + '(Object as any)[`prototype`].IncomingMessage = Capture;\nhttp.createServer({}, () => {});' }, + { form: "PP14: a binder-resolved const key const key = 'prototype'; Object[key]", source: H + OPTS_CAPTURE + `const key = 'prototype';\n(Object as any)[key].IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP15: an optional-chained Object?.prototype', source: H + OPTS_CAPTURE + `(Object?.prototype as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP16: a declaration destructuring const { prototype: p } = Object', source: H + OPTS_CAPTURE + `const { prototype: p } = Object as any;\np.IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP17: a declaration destructuring const { getPrototypeOf: g } = Object', source: H + OPTS_CAPTURE + `const { getPrototypeOf: g } = Object;\n(g({}) as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP18: a declaration destructuring const { __proto__: p } = {}', source: H + OPTS_CAPTURE + `const { __proto__: p } = {} as any;\np.IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP19: an assignment destructuring ({ prototype: p } = Object)', source: H + OPTS_CAPTURE + `let p: any;\n({ prototype: p } = Object as any);\np.IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: "PP20: a reflective Reflect.get(Object, 'prototype')", source: H + OPTS_CAPTURE + `(Reflect.get(Object, 'prototype') as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: "PP21: a reflective Object.getOwnPropertyDescriptor(Object, 'prototype').value", source: H + OPTS_CAPTURE + `(Object.getOwnPropertyDescriptor(Object, 'prototype') as any).value.IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP22: pollution beside CONFINED const-spine options', source: H + OPTS_CAPTURE + `(Object.prototype as any).IncomingMessage = Capture;\nconst o = { maxHeaderSize: 1 };\nhttp.createServer(o, () => {});` }, + { form: 'PP23: pollution inside a function body, options {} at module level', source: H + OPTS_CAPTURE + `function pollute(): void {\n (Object.prototype as any).IncomingMessage = Capture;\n}\npollute();\nhttp.createServer({}, () => {});` }, + { form: 'PP24: pollution via globalThis.Object.prototype', source: H + OPTS_CAPTURE + `(globalThis.Object.prototype as any).IncomingMessage = Capture;\nhttp.createServer({}, () => {});` }, + { form: 'PP25: the pollution SITE in a file with NO createServer options (cross-file soundness: the site denies on its own)', source: H + OPTS_CAPTURE + `(Object.prototype as any).IncomingMessage = Capture;\nhttp.createServer(() => {});` }, + { form: 'PP26: a bare x.__proto__ = {…} re-prototyping (the name is reserved wherever it appears)', source: H + `const x: any = {};\nx.__proto__ = {};\nhttp.createServer({}, () => {});` }, + { form: 'PP27: Object.getPrototypeOf(server) (formerly asserted allowed as a frozen forwarding row; now the reach name itself denies)', source: wrapperHead + `void Object.getPrototypeOf(server);` }, + ]; + for (const { form, source } of prototypePollutionReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MECH-1 prototype-inheritance closure (preserve): benign forms that do not NAME a prototype + // reach keep their disposition; the reservation is exact-name, position-specific. ---- + const prototypePollutionAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a direct inline {} options literal with no pollution', source: H + `http.createServer({}, () => {});` }, + { form: 'confined const-spine options with no pollution', source: H + `const o = { maxHeaderSize: 1 };\nhttp.createServer(o, () => {});` }, + { form: 'the no-options createServer(handler) form', source: H + `http.createServer((req: any, res: any) => {\n res.end(req.method);\n});` }, + { form: 'a superstring member obj.prototypeName', source: `const obj = { prototypeName: 'x' };\nvoid obj.prototypeName;` }, + { form: "string data 'prototype' / '__proto__' / 'getPrototypeOf'", source: `const a = 'prototype';\nconst b = '__proto__';\nconst c = 'getPrototypeOf';\nvoid a;\nvoid b;\nvoid c;` }, + { form: 'object-literal DATA keys { prototype: 1, getPrototypeOf: 2 } (not a destructuring target)', source: `const o = { prototype: 1, getPrototypeOf: 2 };\nvoid o;` }, + { form: 'a class declaration and ordinary new (no prototype member is named)', source: `class X {\n run(): number {\n return 1;\n }\n}\nvoid new X().run();` }, + { form: 'Object.keys / Object.freeze / Object.entries on a local object', source: `const o = { a: 1 };\nvoid Object.keys(o);\nvoid Object.freeze(o);\nvoid Object.entries(o);` }, + { form: 'a type-position `prototype` member', source: `type T = { prototype: number };\ndeclare const t: T;\nvoid t;` }, + { form: "the computed ['__proto__'] and shorthand { __proto__ } object-literal keys stay own properties", source: H + `class X {}\nconst __proto__ = { IncomingMessage: X };\nvoid __proto__;\nhttp.createServer({ ['__proto__']: { IncomingMessage: X } } as any, () => {});` }, + { form: 'the real host shape (helper forwarding of res, inline typed listener)', source: H + `function apply(r: http.ServerResponse): void {\n r.setHeader('X', 'Y');\n}\nhttp.createServer((req: http.IncomingMessage, res: http.ServerResponse): void => {\n apply(res);\n res.end(req.method ?? '');\n});` }, + ]; + for (const { form, source } of prototypePollutionAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } }); // --------------------------------------------------------------------------- From e99d12a1c4c6f5229b8b34692aebdeba5d972a24 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 2 Sep 2026 09:51:21 +0200 Subject: [PATCH 32/35] fix(cockpit): reserve socket delivery emit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AiozMuvvnzkYJgStQH6Qdo --- tests/cockpit-host/purity.test.ts | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index c8cca15..c4fc3fb 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1352,6 +1352,14 @@ const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connect // beside the five event registrars; the whole family is banned by NAME at any position (F2), // never by call shape, so `.call`/`.apply`/`.bind`/`Reflect.apply`/`const m = server.on` // cannot launder it, and there is NO event-name list to maintain. +// EXACT-HEAD CODEX P1: `emit` joins the SAME family — http.Server inherits +// `EventEmitter.prototype.emit`, and Node's internal connection delivery invokes the server's OWN +// overridable `emit` (`server.emit('connection', socket)`, verified on Node v24.12.0), so REPLACING +// or reading `server.emit` receives the live socket exactly as the registrars do. Being a receiver- +// independent member NAME, it is closed at every acquisition position — reads AND the replacement +// writes `server.emit = …` / `server['emit'] = …` (the LHS `server.emit` is a member access the RULE +// A (a)/(b) visit sees regardless of the `=`) — with no emit-specific rule and no EventEmitter/event- +// name analysis. Real host source names none of these members. const SOCKET_DELIVERY_MEMBERS: ReadonlySet = new Set([ 'on', 'once', @@ -1359,6 +1367,7 @@ const SOCKET_DELIVERY_MEMBERS: ReadonlySet = new Set([ 'prependListener', 'prependOnceListener', 'setTimeout', + 'emit', ]); // RULE A family (iii) — CONSTRUCTOR RE-DERIVATION (Day-7 F2). The permitted createServer CALL RESULT // is an http.Server whose ordinary `constructor` property (inherited from `http.Server.prototype`) IS @@ -6556,6 +6565,69 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } + // --- EXACT-HEAD CODEX P1 (SOCKET-DELIVERY member `emit`): a permitted http.Server inherits + // `EventEmitter.prototype.emit`, and Node's internal connection delivery invokes the server's + // OWN overridable `emit` (`server.emit('connection', socket)` — verified on Node v24.12.0: + // `server instanceof EventEmitter`, `server.emit === EventEmitter.prototype.emit`, and a + // replacement receives the live `net.Socket`, which exposes `.destroy()`/`.connect()` for an + // outbound reconnect). `emit` is therefore ONE MORE member of the SAME receiver-independent + // SOCKET_DELIVERY_MEMBERS family as `on`/`once`/`addListener`/`prependListener`/ + // `prependOnceListener`/`setTimeout`: a http.Server method that hands a socket to user code. + // It is closed by the EXISTING RULE A static-name machinery at every acquisition position — no + // emit-specific rule, no value/event-name/EventEmitter analysis. Because the ban anchors on the + // member NAME at ANY position (read OR write), the REPLACEMENT forms (`server.emit = …`, + // `server['emit'] = …`) are closed by the very same property/element-access visit that closes + // the reads — the `server.emit` / `server['emit']` sub-node is a member access regardless of + // which side of the `=` it sits on. MUST REJECT. --- + const emitReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'a dotted read server.emit', source: wrapperHead + `void server.emit;` }, + { form: "a static-computed server['emit']", source: wrapperHead + `void server['emit'];` }, + { form: 'a template-computed server[`emit`]', source: wrapperHead + 'void server[`emit`];' }, + { form: "a concatenated server['em' + 'it']", source: wrapperHead + `void server['em' + 'it'];` }, + { form: "a const-bound server[k] with const k = 'emit'", source: wrapperHead + `const k = 'emit';\nvoid server[k];` }, + { form: 'a declaration destructuring const { emit } = server', source: wrapperHead + `const { emit } = server;\nvoid emit;` }, + { form: 'a renamed declaration destructuring const { emit: e } = server', source: wrapperHead + `const { emit: e } = server;\nvoid e;` }, + { form: 'an assignment destructuring ({ emit: e } = server)', source: wrapperHead + `let e: unknown;\n({ emit: e } = server as unknown as { emit: unknown });\nvoid e;` }, + { form: "a structural reflective Reflect.get(server, 'emit')", source: wrapperHead + `void Reflect.get(server, 'emit');` }, + { form: "a computed-static reflective Reflect.get(server, k) with const k = 'emit'", source: wrapperHead + `const k = 'emit';\nvoid Reflect.get(server, k);` }, + { form: 'an indirect invocation server.emit.call(server, …)', source: wrapperHead + `server.emit.call(server, 'connection', {});` }, + { form: 'an indirect invocation server.emit.apply(server, …)', source: wrapperHead + `server.emit.apply(server, ['connection', {}]);` }, + { form: 'an indirect invocation server.emit.bind(server)', source: wrapperHead + `const b = server.emit.bind(server);\nvoid b;` }, + { form: 'a replacement assignment server.emit = fn', source: wrapperHead + `server.emit = function (): boolean {\n return true;\n};` }, + { form: "a replacement assignment server['emit'] = fn", source: wrapperHead + `server['emit'] = function (): boolean {\n return true;\n};` }, + { form: 'the reported emit-replacement socket capture + outbound reconnect', source: wrapperHead + `const originalEmit = server.emit;\nserver.emit = function (event: any, ...values: any[]) {\n if (event === 'connection') {\n const socket = values[0];\n socket.destroy();\n setTimeout(() => socket.connect(80, 'example.com'), 50);\n }\n return originalEmit.call(this, event, ...values);\n};` }, + // Negative-control PARITY (documented, not silently changed): the delivery family is + // receiver-INDEPENDENT, so an unrelated `localEmitter.emit` is an accepted policy false + // positive exactly like the existing `ee.on('ready', …)` / `emitter.addListener(…)` entries. + // Real cockpit host source names `.emit` nowhere (only the English word "emit"/"emits" inside + // JSDoc comments, which are not AST nodes), so this false positive stays hypothetical. + { form: 'an unrelated localEmitter.emit read (accepted receiver-independent false positive)', source: `declare const localEmitter: { emit(e: string): void };\nconst emit = localEmitter.emit;\nvoid emit;` }, + ]; + for (const { form, source } of emitReject) { + it(`rejects ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- EXACT-HEAD CODEX P1 (SOCKET-DELIVERY member `emit`) — PRECISION: the RULE A name ban fires + // ONLY where `emit` names a value member access, a binding SOURCE key, an assignment + // destructuring key, or a reflective key argument. An object-literal DATA key, a bare string, + // a superstring member (`emitter`/`emitted`), and a type-position method signature are NOT that + // name at an acquisition position and stay allowed. MUST ALLOW. --- + const emitAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'an object-literal DATA key { emit: 1 }', source: `const config = { emit: 1 };\nvoid config;` }, + { form: "a bare string binding const label = 'emit'", source: `const label = 'emit';\nvoid label;` }, + { form: 'a superstring member server.emitter', source: wrapperHead + `void server.emitter;` }, + { form: 'a superstring member server.emitted', source: wrapperHead + `void server.emitted;` }, + { form: 'a type-position emit method signature', source: `interface Sink {\n emit(x: number): void;\n}\ndeclare const s: Sink;\nvoid s;` }, + { form: "an unrelated call arg log('emit')", source: `declare function log(m: string): void;\nlog('emit');` }, + ]; + for (const { form, source } of emitAllow) { + it(`allows ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + // --- CODEX F1 BINDER-KEY MATRIX (SOCK consolidation): the socket acquisition key is now resolved // by TypeScript BINDER identity (`sockResolveKey` → `netResolveKey`), so an unrelated shadowing // same-text `const` in another scope NEVER makes a binder-pinned key unresolved the way the From 3b863191ad1bb79f94df0d4a9759c8a24d5be22d Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 2 Sep 2026 12:29:07 +0200 Subject: [PATCH 33/35] fix(cockpit): close privileged meta mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one canonical, target-gated meta-mutation circuit breaker to the D3 cockpit-host purity analyzer: a free built-in meta-mutation API (Object.defineProperty/defineProperties/assign/setPrototypeOf, Reflect.defineProperty/setPrototypeOf/set) applied to a proven privileged target (a createServer result — direct or via the bounded unique-const spine — or a tracked req/res root) is denied. The gate is on the target, never the member key, so future member names deny identically without any emit/on/constructor enumeration. Reads, capability-removing APIs, and unprovable targets stay allowed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EczoNDvhWL5HRnF5CTPcHz --- tests/cockpit-host/purity.test.ts | 228 ++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index c4fc3fb..38dec25 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1809,6 +1809,36 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou return symbol !== undefined && reqResSymbols.has(symbol); }; + // META-MUTATION CIRCUIT BREAKER — whether an expression is a PROVEN PRIVILEGED TARGET, decided ONLY + // through the EXISTING bounded roots (no new alias graph / taint / call graph / value flow): + // (C/D) a tracked req/res root or its bounded static member chain (`receiverIsReqRes`); OR + // (A) a DIRECT `http.createServer(…)` call / `new` result (`isCreateServerCall`); OR + // (B) that result reached through the SAME bounded unique-`const` alias spine the createServer + // argument resolver uses — `binderUnwrap` transparent wrappers plus `netUniqueConstDecl` + // (binder identity, exactly one `const` declaration), iterated so `const b = a; const a = + // http.createServer(…)` resolves, with an explicit hop cap and a visited-declaration set for + // termination. + // Every OTHER target — a `let`/`var`/parameter/property, a call result (`getServer()`), a spread, + // an ambient binding, an alias cycle — is NOT proven privileged and stays OUTSIDE the finite proof + // (allowed here; the receiver-independent NAME bans still see a syntactic `server.emit`). This is + // bounded local NORMALIZATION scoped to the meta-mutation boundary, NOT value flow: the resolved + // node's value/body/constructor is never read. + const targetIsPrivileged = (expr: ts.Expression): boolean => { + if (receiverIsReqRes(expr)) return true; + let cur: ts.Expression = binderUnwrap(expr); + const seen = new Set(); + for (let hops = 0; hops < CS_ARG_RESOLVE_HOP_CAP; hops++) { + if (isCreateServerCall(cur)) return true; + if (!ts.isIdentifier(cur)) return false; + const decl = netUniqueConstDecl(cur, checker); + if (decl === null || decl.initializer === undefined) return false; // let/var/param/property/no-init: stop + if (seen.has(decl)) return false; // alias cycle: stop in finite time + seen.add(decl); + cur = binderUnwrap(decl.initializer); // unique `const` hop (const→const spine included) + } + return false; + }; + // RULE A2 (assignment-target walk — MECH-2 object-rest + nested fail-close) — the assignment twin of // `scanReqResBindingPattern`, walked over the finite ObjectLiteral/ArrayLiteral DESTRUCTURING // TARGET of an `=` whose right-hand side is a tracked req/res receiver. Per property: a @@ -2051,6 +2081,29 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou } } } + // META-MUTATION CIRCUIT BREAKER — a direct call to a FREE, unshadowed built-in META-MUTATION API + // (`isMetaMutationCallee`: `Object.defineProperty`/`defineProperties`/`assign`/`setPrototypeOf`, + // `Reflect.defineProperty`/`setPrototypeOf`/`set` — dotted or bounded static element form) INSTALLS + // behavior on / re-parents its FIRST argument as a first-class value, so no `server.emit` member + // node exists for RULE A's name ban to see. The disposition is TARGET-GATED, never key-gated: + // DENY iff the target argument is a PROVEN PRIVILEGED TARGET (`targetIsPrivileged` — a createServer + // result, direct or via the bounded unique-`const` spine, or a tracked req/res root). The member + // KEY / descriptor / source / prototype is NEVER inspected — that is the entire circuit-breaker: + // `Object.defineProperty(server, 'futureMemberX', …)` denies exactly like `…(server, 'emit', …)`, + // with no `emit`/`on`/`constructor` enumeration. An UNPRIVILEGED target + // (`Object.defineProperty(config, 'emit', …)`, `Object.assign({}, { emit: 1 })`), a non-mutating + // API outside the inventory (`Object.freeze(server)`), reading FROM a server into a fresh object + // (`Object.assign({}, server)`), and an unprovable target (`Object.defineProperty(getServer(), …)` + // — value flow / arbitrary return, outside the finite proof) all stay allowed. A SPREAD in the + // target position (`Object.defineProperty(...args)`) is unsupported target propagation and stays + // outside the proof. Because the gate is on the target, an indeterminate KEY at a privileged + // target is already fail-closed (the target alone denies); no key resolution is needed. + if (ts.isCallExpression(node) && isMetaMutationCallee(node.expression, checker, sourceFile)) { + const target = node.arguments[0]; + if (target !== undefined && !ts.isSpreadElement(target) && targetIsPrivileged(target)) { + found = true; + } + } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); @@ -2419,6 +2472,64 @@ const isSockReflectiveReadCallee = (callee: ts.Expression, checker: ts.TypeCheck return member !== null && apis.has(member); }; +// META-MUTATION CIRCUIT BREAKER (reflective/meta-mutation sibling family) — the FINITE, closed +// inventory of free built-in META-MUTATION APIs that INSTALL behavior on / RE-PARENT a target object by +// operating on it as a first-class value (a KEY/DESCRIPTOR/SOURCE/PROTOTYPE argument), bypassing RULE +// A's syntactic member-name ban: `Object.defineProperty`/`defineProperties`/`assign`/`setPrototypeOf` +// and `Reflect.defineProperty`/`setPrototypeOf`/`set` (`Reflect.set` is the reflective property WRITE, the +// function twin of `server[key] = fn`, installing behavior under a key with no member node). +// Node invokes a server's OWN `emit('connection', socket)` +// internally, so replacing (or shadowing, via a re-parented prototype) ANY member of a privileged server +// delivers the live socket — the danger is the MUTATION of a privileged target, not a particular member +// name. This is the mutation twin of `SOCK_REFLECTIVE_READ_APIS`, keyed by base identifier the SAME way; +// like it, it is NOT a general reflective/value-flow model. Pure READS that merely EXPOSE the surface +// (`Object.getOwnPropertyDescriptors`, `Object.getPrototypeOf`, `Reflect.getPrototypeOf`) are DELIBERATELY +// excluded: their weaponization requires either NAMING the extracted member (already denied +// receiver-independently by RULE A — `getPrototypeOf(server).emit` is a `.emit` access) or VALUE FLOW +// (outside the frozen boundary); a read creates no authority, so no authority-creation reason admits it. +// Capability-REMOVING APIs (`Object.freeze`/`seal`/`preventExtensions`) are excluded for the same reason. +const META_MUTATION_APIS: ReadonlyMap> = new Map([ + ['Object', new Set(['defineProperty', 'defineProperties', 'assign', 'setPrototypeOf'])], + ['Reflect', new Set(['defineProperty', 'setPrototypeOf', 'set'])], +]); +// The longest API name (`defineProperties` = 16), DERIVED from the inventory so a static element-access +// spelling of the member (`Object['definePro' + 'perties']`) folds to it rather than being pruned as +// NotCapability under the narrower socket/network ceilings. +const MAX_META_MUTATION_API_LENGTH = Math.max( + ...[...META_MUTATION_APIS.values()].flatMap((names) => [...names]).map((name) => name.length), +); +// Whether a call callee is a DIRECT free built-in META-MUTATION member: a dotted +// `Object.defineProperty` / `Reflect.setPrototypeOf` (optional chaining included) or a bounded static +// element access `Object['assign']` / `Object['definePro' + 'perties']`, with the member name resolved by +// the SAME `netResolveKey` machinery (binder identity, never identifier text) at the API-name ceiling, +// off a BASE identifier the binder proves UNSHADOWED — the `isSockReflectiveReadCallee` structure reused +// verbatim (`hasLocalRuntimeShadow`): a local `const Object = { defineProperty() {} }` is an ordinary +// object. An aliased base (`const O = Object`), a runtime member key (`Object[k]`), or a shadowed base all +// fail this — one statically identifiable built-in member, no alias, no call/apply/bind, no wrapper. +const isMetaMutationCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const base = binderUnwrap(c.expression); + if (!ts.isIdentifier(base)) return false; + const apis = META_MUTATION_APIS.get(base.text); + if (apis === undefined) return false; + const symbol = checker.getSymbolAtLocation(base); + if (symbol !== undefined && hasLocalRuntimeShadow(symbol, sourceFile)) return false; + let member: string | null; + if (ts.isPropertyAccessExpression(c)) { + member = c.name.text; + } else { + try { + const key = netResolveKey(c.argumentExpression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_META_MUTATION_API_LENGTH); + member = key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (!(error instanceof NetResolveAbort)) throw error; + member = null; + } + } + return member !== null && apis.has(member); +}; + // DDR-NET-STATIC-KEY-PARITY (nested authority) — the self-reference HOP NAME a DESTRUCTURING key // denotes, resolved by the SAME bounded binder resolver used for a member-access hop (`netHopName`) // but reading a binding/object-literal KEY node instead of an element-access argument: a plain @@ -6628,6 +6739,123 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } + // --- META-MUTATION CIRCUIT BREAKER (reflective/meta-mutation sibling family) — a GENERIC + // meta-mutation built-in (`Object.defineProperty`/`defineProperties`/`assign`/`setPrototypeOf`, + // `Reflect.defineProperty`/`setPrototypeOf`) applied to a PROVEN PRIVILEGED TARGET installs + // attacker-controlled behavior under a member WITHOUT any `server.emit` member node for RULE A's + // name ban to see — Node then invokes the server's OWN `emit('connection', socket)` internally and + // delivers the live socket. Closed by ONE canonical rule: recognized `META_MUTATION_APIS` callee + + // `targetIsPrivileged(arg0)`. The TARGET gate is the whole mechanism, so the specific member key is + // IRRELEVANT (no `emit`/`on`/`constructor`/`futureMemberX` enumeration) — #15 is the key-independence + // witness. A privileged target is proven ONLY through existing bounded roots: a direct + // `http.createServer` call/new, that result through the bounded unique-`const` alias spine, or a + // tracked req/res root (`receiverIsReqRes`). MUST REJECT. --- + const metaServerHead = H + `const server = http.createServer(() => {});\n`; + const metaReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: '1. Object.defineProperty(server, "emit", { value })', source: metaServerHead + `Object.defineProperty(server, 'emit', { value() { return true; } });` }, + { form: '2. Object.defineProperty(server, "on", { value })', source: metaServerHead + `Object.defineProperty(server, 'on', { value() { return true; } });` }, + { form: '3. Object.defineProperty(server, "constructor", { value })', source: metaServerHead + `Object.defineProperty(server, 'constructor', { value() { return true; } });` }, + { form: '4. Reflect.defineProperty(server, "emit", descriptor)', source: metaServerHead + `Reflect.defineProperty(server, 'emit', { value() { return true; } });` }, + { form: '5. Object.defineProperties(server, { emit: { value } })', source: metaServerHead + `Object.defineProperties(server, { emit: { value() { return true; } } });` }, + { form: '6. Object.assign(server, { emit })', source: metaServerHead + `Object.assign(server, { emit() { return true; } });` }, + { form: '7. Object.setPrototypeOf(server, evilProto)', source: metaServerHead + `Object.setPrototypeOf(server, {});` }, + { form: '8. Reflect.setPrototypeOf(server, evilProto)', source: metaServerHead + `Reflect.setPrototypeOf(server, {});` }, + { form: '9a. server reached through the bounded const-alias spine', source: metaServerHead + `const s2 = server;\nconst s3 = s2;\nObject.defineProperty(s3, 'emit', { value() { return true; } });` }, + { form: '9b. the direct createServer call result as target', source: H + `Object.defineProperty(http.createServer(() => {}), 'emit', { value() { return true; } });` }, + { form: '10. a proven req target', source: handler(`Object.defineProperty(req, 'emit', { value() { return true; } });`) }, + { form: '11. a proven res target', source: handler(`Object.defineProperty(res, 'emit', { value() { return true; } });`) }, + { form: '12. computed-static key const k = "emit"; …(server, k, …)', source: metaServerHead + `const k = 'emit';\nObject.defineProperty(server, k, { value() { return true; } });` }, + { form: '13. binder-resolved concatenated key …(server, "em" + "it", …)', source: metaServerHead + `Object.defineProperty(server, 'em' + 'it', { value() { return true; } });` }, + { form: '14. full Codex emit replacement + live-socket outbound reconnect witness', source: metaServerHead + `Object.defineProperty(server, 'emit', {\n value(event: string, ...values: unknown[]): boolean {\n if (event === 'connection') {\n const socket = values[0] as { destroy(): void; connect(p: number, h: string): void };\n socket.destroy();\n setTimeout(() => socket.connect(80, 'example.com'), 50);\n }\n return true;\n },\n});` }, + { form: '15. future-member key witness …(server, "futureMemberX", …) — key-independence', source: metaServerHead + `Object.defineProperty(server, 'futureMemberX', { value() { return true; } });` }, + ]; + for (const { form, source } of metaReject) { + it(`rejects a meta-mutation of a privileged target: ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- META-MUTATION CIRCUIT BREAKER — FALSE-POSITIVE CONTROLS. The rule is TARGET-GATED, never + // key-gated: a meta-mutation of an UNRELATED target stays allowed even when the key is `emit`/ + // `socket`; benign non-mutating `Object.*` APIs (`freeze`/`seal`/`preventExtensions`, which REMOVE + // capability and are deliberately outside `META_MUTATION_APIS`) stay allowed on any target; reading + // FROM a privileged server into a fresh object (`Object.assign({}, server)`) is arg0-unprivileged; + // and ordinary host/string uses are untouched. MUST ALLOW. --- + const metaAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'Object.defineProperty(config, "emit", …) on an unrelated object', source: `const config = { a: 1 };\nObject.defineProperty(config, 'emit', { value() { return true; } });` }, + { form: 'Object.defineProperty(config, "socket", …) on an unrelated object', source: `const config = { a: 1 };\nObject.defineProperty(config, 'socket', { value() { return true; } });` }, + { form: 'Object.assign({}, harmless)', source: `const harmless = { a: 1 };\nObject.assign({}, harmless);` }, + { form: 'Object.assign({}, { emit: 1 })', source: `Object.assign({}, { emit: 1 });` }, + { form: 'Object.assign({}, server) — reading FROM a privileged server (arg0 unprivileged)', source: metaServerHead + `Object.assign({}, server);` }, + { form: 'Object.freeze(benignObject)', source: `const benignObject = { a: 1 };\nObject.freeze(benignObject);` }, + { form: 'Object.seal(benignObject)', source: `const benignObject = { a: 1 };\nObject.seal(benignObject);` }, + { form: 'Object.preventExtensions(benignObject)', source: `const benignObject = { a: 1 };\nObject.preventExtensions(benignObject);` }, + { form: 'Object.freeze(server) on a privileged target — non-mutating, outside META_MUTATION_APIS', source: metaServerHead + `Object.freeze(server);` }, + { form: 'ordinary benign Object.defineProperty on an unrelated target', source: `const obj = { a: 1 };\nObject.defineProperty(obj, 'x', { value: 1 });\nvoid Object.keys(obj);` }, + { form: "res.setHeader('connection', 'close')", source: handler(`res.setHeader('connection', 'close');`) }, + { form: "map.get('connection')", source: `declare const map: { get(k: string): unknown };\nvoid map.get('connection');` }, + { form: "log('socket')", source: `declare function log(m: string): void;\nlog('socket');` }, + { form: "log('emit')", source: `declare function log(m: string): void;\nlog('emit');` }, + ]; + for (const { form, source } of metaAllow) { + it(`allows a benign / unprivileged meta-op: ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // --- META-MUTATION CIRCUIT BREAKER — DOCUMENTED OUTSIDE BOUNDARY. Target propagation through an + // arbitrary call result or a function return is NOT part of the finite proof (no value flow, call + // graph, or container round-trip). These meta-mutations of a genuinely-privileged-at-runtime server + // reached through an unprovable target therefore stay OUTSIDE the boundary and are NOT closed here + // — asserted so the boundary is explicit and honest, exactly as `createServer(getOptions())` stays + // outside the frozen positive model. The receiver-independent NAME bans still catch a syntactic + // `server.emit` on these servers; only the target-gated meta-mutation is out of reach. --- + const metaBoundaryAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'Object.defineProperty(getServer(), "emit", …) — arbitrary call-result target', source: H + `declare function getServer(): http.Server;\nObject.defineProperty(getServer(), 'emit', { value() { return true; } });` }, + { form: 'a function-return server (const server = makeServer()) target', source: wrapperHead + `Object.defineProperty(server, 'emit', { value() { return true; } });` }, + ]; + for (const { form, source } of metaBoundaryAllow) { + it(`leaves outside the finite proof (documented boundary): ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // --- META-MUTATION CIRCUIT BREAKER — FINAL FAMILY MEMBER `Reflect.set` (reflective property WRITE). + // `Reflect.set(target, key, value)` is the function twin of `target[key] = value`: it installs + // attacker behavior under ANY member of a proven privileged target with NO `server.emit` member + // node, exactly like `Reflect.defineProperty`. It joins the SAME target-gated `META_MUTATION_APIS` + // family (`Reflect` → …, `'set'`) — no `Reflect.set`-specific branch, no `emit`/key logic; the + // target gate alone decides, so any key (`emit`/`on`/`constructor`/`futureMemberX`) denies on a + // proven server/req/res. MUST REJECT. --- + const reflectSetReject: readonly { readonly form: string; readonly source: string }[] = [ + { form: "Reflect.set(server, 'emit', fn)", source: metaServerHead + `Reflect.set(server, 'emit', function () { return true; });` }, + { form: "Reflect.set(server, 'on', fn)", source: metaServerHead + `Reflect.set(server, 'on', function () { return true; });` }, + { form: "Reflect.set(server, 'constructor', fn)", source: metaServerHead + `Reflect.set(server, 'constructor', function () { return true; });` }, + { form: "computed-static key const k = 'emit'; Reflect.set(server, k, fn)", source: metaServerHead + `const k = 'emit';\nReflect.set(server, k, function () { return true; });` }, + { form: "Reflect.set(constAliasServer, 'futureMemberX', fn) — spine + key-independence", source: metaServerHead + `const s2 = server;\nReflect.set(s2, 'futureMemberX', function () { return true; });` }, + { form: "Reflect.set(req, 'futureMemberX', fn)", source: handler(`Reflect.set(req, 'futureMemberX', function () { return true; });`) }, + { form: "Reflect.set(res, 'futureMemberX', fn)", source: handler(`Reflect.set(res, 'futureMemberX', function () { return true; });`) }, + ]; + for (const { form, source } of reflectSetReject) { + it(`rejects the reflective-write final family member: ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // --- META-MUTATION CIRCUIT BREAKER — `Reflect.set` FALSE-POSITIVE CONTROLS: the rule stays + // TARGET-gated, never key-gated, so a reflective write to an UNRELATED target is allowed even with + // an `emit`/`socket`/future-member key. MUST ALLOW. --- + const reflectSetAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: "Reflect.set(config, 'emit', fn) on an unrelated object", source: `const config = { a: 1 };\nReflect.set(config, 'emit', function () { return true; });` }, + { form: "Reflect.set({}, 'socket', value)", source: `Reflect.set({}, 'socket', 1);` }, + { form: "Reflect.set(unrelatedObject, 'futureMemberX', value)", source: `const unrelatedObject = { a: 1 };\nReflect.set(unrelatedObject, 'futureMemberX', 1);` }, + ]; + for (const { form, source } of reflectSetAllow) { + it(`allows a reflective write to an unprivileged target: ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + // --- CODEX F1 BINDER-KEY MATRIX (SOCK consolidation): the socket acquisition key is now resolved // by TypeScript BINDER identity (`sockResolveKey` → `netResolveKey`), so an unrelated shadowing // same-text `const` in another scope NEVER makes a binder-pinned key unresolved the way the From 437f74777b664acd2e12f59556bfae0daa95ec86 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 2 Sep 2026 14:47:02 +0200 Subject: [PATCH 34/35] fix(cockpit): enforce structural privileged-target policy --- tests/cockpit-host/purity.test.ts | 696 +++++++++++++++++++++++++++--- 1 file changed, 633 insertions(+), 63 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index 38dec25..a4ee2fa 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -1446,6 +1446,21 @@ const staticKeyText = (key: ts.Node | undefined): string | null => { return null; }; +// MECH-D3 positive-operation allowlists (D3 STRUCTURAL provenance/position model). These are the +// FINITE approved operations for each proven target class — the demonstrated host requirements and +// nothing more. They are POSITIVE allowlists, NOT banned-name tables: `SOCKET_DELIVERY_MEMBERS` and +// `META_MUTATION_APIS` are frozen and untouched. The mechanism denies-by-default, so an unknown +// member (`futureMemberX`) is rejected by ABSENCE from these sets, never by presence in a deny list — +// which is exactly what makes the rule key/name-independent (no dangerous spelling is ever enumerated). +// SERVER — may be CALLED only as `.listen(...)` / `.close(...)`. +// REQUEST — may be READ only as `.method` / `.url`. +// RESPONSE — may be CALLED only as `.setHeader(...)` / `.end(...)`, or WRITTEN `statusCode =` a +// NumericLiteral. +const D3_SERVER_CALL_MEMBERS: ReadonlySet = new Set(['listen', 'close']); +const D3_REQUEST_READ_MEMBERS: ReadonlySet = new Set(['method', 'url']); +const D3_RESPONSE_CALL_MEMBERS: ReadonlySet = new Set(['setHeader', 'end']); +const D3_RESPONSE_WRITE_MEMBERS: ReadonlySet = new Set(['statusCode']); + const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { // MECH-1 (Day-7 convergence): the privileged createServer BOUNDARY is a CallExpression OR a // NewExpression whose callee is binder-proven CREATE_SERVER. `new http.createServer(…)` runs the @@ -2107,6 +2122,369 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); + + // ========================================================================================== + // MECH-D3 (structural provenance/position model) — the AUTHORIZED D3 repair, layered OVER the + // receiver-independent name bans above as defense-in-depth (this pass only ADDS denials, so no + // historical DENY is weakened). The invariant is a single positive rule: + // + // PROVEN PRIVILEGED TARGET + NON-ALLOWLISTED OPERATION = DENY. + // + // A proven SERVER / REQUEST / RESPONSE may occur ONLY in its finite approved operations + // (`D3_*_MEMBERS`, plus alias / factory-return / propagation / neutral positions); EVERY other + // operation denies STRUCTURALLY, without this code ever naming the dangerous member. So + // `server.futureMemberX = …` denies exactly as `server.emit = …` would — key/name-independently, + // with no deny list to grow. Provenance is decided ONLY through the EXISTING bounded roots — the + // createServer call/new result, the createServer listener parameters, unique non-exported `const` + // aliases, approved local SERVER factories, and one-hop local-function parameter propagation — + // with binder identity and finite hop/visit bounds. NO module graph, taint, unrestricted call + // graph, or runtime interpretation: a resolved node's value/body is never interpreted, only its + // syntactic POSITION is classified. + // ========================================================================================== + { + type D3Class = 'SERVER' | 'REQUEST' | 'RESPONSE'; + const D3_ITERATION_CAP = 64; + + // A binder-proven LOCAL runnable function an identifier denotes — a bodied, non-ambient + // `FunctionDeclaration`, or a unique `const` bound to an Arrow/FunctionExpression — else null. + // NEVER an import, ambient declaration, method, or member: a propagation/factory callee must be + // in-file code whose parameters and returns this pass can inspect. This is the ONLY callee shape + // a proven target may be passed to (besides the free reflective-read shape); every other callee + // (`Object.defineProperty`, an aliased mutator, a comma/element callee) is unresolvable here and + // the argument denies. + const resolveLocalFunction = ( + callee: ts.Expression, + ): ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression | null => { + const u = binderUnwrap(callee); + if (!ts.isIdentifier(u)) return null; + const symbol = checker.getSymbolAtLocation(u); + const decls = symbol?.declarations; + if (decls === undefined || decls.length !== 1) return null; + const decl = decls[0]; + if (decl === undefined) return null; + if (ts.isFunctionDeclaration(decl)) { + return decl.body !== undefined && !isInAmbientContext(decl) ? decl : null; + } + const constDecl = netUniqueConstDecl(u, checker); + if (constDecl === null || constDecl.initializer === undefined || isInAmbientContext(constDecl)) return null; + const init = binderUnwrap(constDecl.initializer); + return ts.isArrowFunction(init) || ts.isFunctionExpression(init) ? init : null; + }; + + // Whether a VariableDeclaration is a UNIQUE NON-EXPORTED `const` with an identifier name — the + // sole approved alias / createServer-result binding shape. A `let`/`var`, an exported binding, or + // a destructuring name is NOT confined and denies (an unsupported result shape / identity escape). + const isConfinedConstBinding = (vd: ts.VariableDeclaration): boolean => { + if (!ts.isIdentifier(vd.name)) return false; + const list = vd.parent as ts.Node | undefined; + if (list === undefined || !ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) { + return false; + } + const statement = list.parent as ts.Node | undefined; + if ( + statement !== undefined && + ts.isVariableStatement(statement) && + ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true + ) { + return false; + } + return true; + }; + + const provenClasses = new Map>(); + const serverFactories = new Set(); + const addClass = (symbol: ts.Symbol | undefined, cls: D3Class): boolean => { + if (symbol === undefined) return false; + let set = provenClasses.get(symbol); + if (set === undefined) { + set = new Set(); + provenClasses.set(symbol, set); + } + if (set.has(cls)) return false; + set.add(cls); + return true; + }; + + // A value expression that PRODUCES a proven SERVER: a createServer call/new, a reference to a + // proven SERVER binding, or a call to a proven local SERVER factory. (Depends on the current + // fixpoint state, so it is re-evaluated each iteration.) + const exprIsServerValue = (e: ts.Expression): boolean => { + const u = binderUnwrap(e); + if (isCreateServerCall(u)) return true; + if (ts.isIdentifier(u)) { + const symbol = checker.getSymbolAtLocation(u); + return symbol !== undefined && provenClasses.get(symbol)?.has('SERVER') === true; + } + if (ts.isCallExpression(u)) { + const fn = resolveLocalFunction(u.expression); + return fn !== null && serverFactories.has(fn); + } + return false; + }; + + // The classes an initializer confers on its binding: SERVER for a server value; the alias + // source's classes for a proven-binding identifier (so `const r = req` carries REQUEST). + const classesOfValue = (e: ts.Expression): readonly D3Class[] => { + const out: D3Class[] = []; + if (exprIsServerValue(e)) out.push('SERVER'); + const u = binderUnwrap(e); + if (ts.isIdentifier(u)) { + const symbol = checker.getSymbolAtLocation(u); + const set = symbol !== undefined ? provenClasses.get(symbol) : undefined; + if (set !== undefined) { + for (const c of set) if (!out.includes(c)) out.push(c); + } + } + return out; + }; + + // The finite OWN-body return values of a function (its arrow expression body, or the argument of + // every `return` NOT inside a nested function/class scope). No value flow — the returned nodes are + // only tested for the server-value SHAPE by the factory rule below. + const ownReturnValues = ( + fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, + ): ts.Expression[] => { + if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) return [fn.body]; + const body = fn.body; + if (body === undefined || !ts.isBlock(body)) return []; + const out: ts.Expression[] = []; + const walk = (n: ts.Node): void => { + if ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isClassDeclaration(n) || + ts.isClassExpression(n) + ) { + return; // a nested scope: its returns are not this function's + } + if (ts.isReturnStatement(n) && n.expression !== undefined) out.push(n.expression); + ts.forEachChild(n, walk); + }; + ts.forEachChild(body, walk); + return out; + }; + + // Visit every function-like createServer listener argument's parameter list (through the EXISTING + // bounded `resolveCreateServerArgument`, so a named / const-bound handler is covered exactly like + // a direct inline one). Object-literal (options) arguments are skipped. + const eachListenerParams = (visit: (params: ts.NodeArray) => void): void => { + const walk = (n: ts.Node): void => { + if (isCreateServerCall(n)) { + for (const arg of n.arguments ?? []) { + const src = resolveCreateServerArgument(arg); + if (ts.isArrowFunction(src) || ts.isFunctionExpression(src) || ts.isFunctionDeclaration(src)) { + visit(src.parameters); + } + } + } + ts.forEachChild(n, walk); + }; + ts.forEachChild(sourceFile, walk); + }; + + // ---- Phase A — bounded fixpoint over provenance (roots, aliases, factories, propagation). ---- + for (let iter = 0; iter < D3_ITERATION_CAP; iter++) { + // A holder object (not a bare `let`), so the flag mutated inside the nested walk callbacks is + // read as `boolean` after those calls return — a plain `let` would be flow-narrowed to its + // `false` initializer (closure mutations are invisible to the checker) and the loop guard + // would read as an always-true `!changed`. + const flags = { changed: false }; + // (a) createServer listener parameters: parameter 0 → REQUEST, parameter 1 → RESPONSE (a plain + // identifier, non-rest; destructured/rest parameters stay with the existing RULE A2 machinery). + eachListenerParams((params) => { + params.forEach((param, index) => { + if (index > 1 || param.dotDotDotToken !== undefined || !ts.isIdentifier(param.name)) return; + if (addClass(checker.getSymbolAtLocation(param.name), index === 0 ? 'REQUEST' : 'RESPONSE')) { + flags.changed = true; + } + }); + }); + // (b) confined-`const` bindings: a server value, or an alias of a proven binding. + const walkBindings = (n: ts.Node): void => { + if (ts.isVariableDeclaration(n) && n.initializer !== undefined && isConfinedConstBinding(n)) { + const symbol = checker.getSymbolAtLocation(n.name); + for (const cls of classesOfValue(n.initializer)) if (addClass(symbol, cls)) flags.changed = true; + } + ts.forEachChild(n, walkBindings); + }; + ts.forEachChild(sourceFile, walkBindings); + // (c) approved local SERVER factories: a local function whose (≥1) own returns are ALL server values. + const walkFactories = (n: ts.Node): void => { + if ( + (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n)) && + !serverFactories.has(n) && + n.body !== undefined && + !isInAmbientContext(n) + ) { + const returns = ownReturnValues(n); + if (returns.length > 0 && returns.every((r) => exprIsServerValue(r))) { + serverFactories.add(n); + flags.changed = true; + } + } + ts.forEachChild(n, walkFactories); + }; + ts.forEachChild(sourceFile, walkFactories); + // (d) one-hop propagation: a proven target passed to a local function taints that parameter. + const walkPropagation = (n: ts.Node): void => { + if (ts.isCallExpression(n)) { + const fn = resolveLocalFunction(n.expression); + if (fn !== null) { + n.arguments.forEach((arg, index) => { + if (ts.isSpreadElement(arg)) return; + const classes = classesOfValue(arg); + if (classes.length === 0) return; + const param = fn.parameters[index]; + if (param === undefined || param.dotDotDotToken !== undefined || !ts.isIdentifier(param.name)) return; + const symbol = checker.getSymbolAtLocation(param.name); + for (const cls of classes) if (addClass(symbol, cls)) flags.changed = true; + }); + } + } + ts.forEachChild(n, walkPropagation); + }; + ts.forEachChild(sourceFile, walkPropagation); + if (!flags.changed) break; + } + + // ---- Phase B — classify every USE of a proven target by its structural POSITION. ---- + const memberReadAllowed = (name: string, cls: D3Class): boolean => + cls === 'REQUEST' && D3_REQUEST_READ_MEMBERS.has(name); + + const isAssignmentOp = (kind: ts.SyntaxKind): boolean => + kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; + + // Classify a member/element operation on a proven target by its parent form: an assignment + // (only `RESPONSE.statusCode = NumericLiteral` allowed), compound/update/delete (write → DENY), + // a call (the class call allowlist), or a plain read (the class read allowlist). The member NAME + // is compared ONLY against the small positive allowlists — never a deny list. + const memberOpAllowed = ( + access: ts.PropertyAccessExpression | ts.ElementAccessExpression, + name: string, + cls: D3Class, + ): boolean => { + const top = outermostTransparentWrapper(access); + const gp = top.parent as ts.Node | undefined; + if (gp === undefined) return false; + if (ts.isBinaryExpression(gp) && gp.left === top && isAssignmentOp(gp.operatorToken.kind)) { + return ( + gp.operatorToken.kind === ts.SyntaxKind.EqualsToken && + cls === 'RESPONSE' && + D3_RESPONSE_WRITE_MEMBERS.has(name) && + ts.isNumericLiteral(binderUnwrap(gp.right)) + ); + } + if (ts.isPostfixUnaryExpression(gp) && gp.operand === top) return false; // update ++/-- + if ( + ts.isPrefixUnaryExpression(gp) && + (gp.operator === ts.SyntaxKind.PlusPlusToken || gp.operator === ts.SyntaxKind.MinusMinusToken) && + gp.operand === top + ) { + return false; // update ++/-- + } + if (ts.isDeleteExpression(gp) && gp.expression === top) return false; // delete + if (ts.isCallExpression(gp) && gp.expression === top) { + if (cls === 'SERVER') return D3_SERVER_CALL_MEMBERS.has(name); + if (cls === 'RESPONSE') return D3_RESPONSE_CALL_MEMBERS.has(name); + return D3_REQUEST_READ_MEMBERS.has(name); // REQUEST: `.method`/`.url` read, then the string is called + } + return memberReadAllowed(name, cls); // a plain read + }; + + // Whether the operation applied to a proven-target occurrence `w` (already unwrapped through + // transparent wrappers) is in the approved set for `cls`. + const opAllowedForClass = (w: ts.Node, cls: D3Class): boolean => { + const p = w.parent as ts.Node | undefined; + if (p === undefined) return false; + // neutral / terminal positions (any class) + if (ts.isExpressionStatement(p) && p.expression === w) return true; + if (ts.isVoidExpression(p) && p.expression === w) return true; + if (ts.isTypeOfExpression(p) && p.expression === w) return true; + // factory return / arrow expression body (SERVER only) + if (ts.isReturnStatement(p) && p.expression === w) return cls === 'SERVER'; + if (ts.isArrowFunction(p) && p.body === w) return cls === 'SERVER'; + // a variable binding: a unique non-exported `const` IDENTIFIER binding is the approved alias / + // result form; a BINDING-PATTERN name is a DESTRUCTURING whose per-key disposition off a + // tracked REQUEST/RESPONSE is already owned by the existing RULE A2 / A(c) machinery + // (static-harmless allowed, socket-name / object-rest / indeterminate-key denied there), so + // this pass defers it; a SERVER destructured here is an unsupported result-shape escape. + if (ts.isVariableDeclaration(p) && p.initializer === w) { + if (ts.isIdentifier(p.name)) return isConfinedConstBinding(p); + return cls !== 'SERVER'; + } + // a destructuring ASSIGNMENT `({ … } = req)` / `[ … ] = req` off a tracked REQUEST/RESPONSE is + // likewise owned by the existing RULE A2 assignment machinery; a SERVER (or an assignment + // into a plain identifier/member target) is an escape. + if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === w) { + const lhs = binderUnwrap(p.left); + if (ts.isObjectLiteralExpression(lhs) || ts.isArrayLiteralExpression(lhs)) return cls !== 'SERVER'; + return false; + } + // member / element operation + if (ts.isPropertyAccessExpression(p) && p.expression === w) return memberOpAllowed(p, p.name.text, cls); + if (ts.isElementAccessExpression(p) && p.expression === w) { + const key = sockResolveKey(p.argumentExpression, checker, sockMemo); + if (key.kind !== 'resolved') return false; // runtime / oversized-static key on a proven target: DENY + return memberOpAllowed(p, key.value, cls); + } + // call / new argument — the CALLEE RULE + if (ts.isCallExpression(p) && p.expression !== w && p.arguments.some((a) => a === w)) { + // (1) the existing binder-proven free reflective-read shape, subject to key policy (arg0 only) + if (isSockReflectiveReadCallee(p.expression, checker, sourceFile) && p.arguments[0] === w) { + const keyArg = p.arguments[1]; + if (keyArg === undefined || ts.isSpreadElement(keyArg)) return false; + const key = sockResolveKey(keyArg, checker, sockMemo); + return key.kind === 'resolved' && memberReadAllowed(key.value, cls); + } + // (2) an approved propagating local function (the parameter was tainted in Phase A) + if (resolveLocalFunction(p.expression) !== null) return true; + return false; // any other callee: DENY (Object.defineProperty, aliased mutator, comma/element callee, …) + } + if (ts.isNewExpression(p) && (p.arguments ?? []).some((a) => a === w)) return false; + return false; // container element / object value / spread / export / any other escape + }; + + const classifyUse = (node: ts.Node, classes: Iterable): void => { + const w = outermostTransparentWrapper(node); + for (const cls of classes) { + if (!opAllowedForClass(w, cls)) { + found = true; + return; + } + } + }; + + const SERVER_ONLY: readonly D3Class[] = ['SERVER']; + const classifyWalk = (n: ts.Node): void => { + // THIS RESERVATION — a whole-file ThisExpression is rejected: `this` can re-derive a privileged + // receiver, and the real host has no ThisExpression. No `this` provenance is modeled. + if (n.kind === ts.SyntaxKind.ThisKeyword) found = true; + if (isCreateServerCall(n)) { + classifyUse(n, SERVER_ONLY); // the createServer result value's own position + } else if (ts.isCallExpression(n)) { + const fn = resolveLocalFunction(n.expression); + if (fn !== null && serverFactories.has(fn)) classifyUse(n, SERVER_ONLY); // a factory-call result value + } else if (ts.isIdentifier(n) && isBinderValueReference(n)) { + // A shorthand `{ server }` reads the VALUE binding through `getShorthandAssignmentValueSymbol` + // (the name identifier alone resolves to the fresh property symbol); every other value + // reference resolves directly. The shorthand's POSITION then denies as a container escape. + const parent = n.parent as ts.Node | undefined; + const symbol = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === n + ? checker.getShorthandAssignmentValueSymbol(parent) + : checker.getSymbolAtLocation(n); + const set = symbol !== undefined ? provenClasses.get(symbol) : undefined; + if (set !== undefined && set.size > 0) classifyUse(n, set); + } + ts.forEachChild(n, classifyWalk); + }; + ts.forEachChild(sourceFile, classifyWalk); + } + return found; }; @@ -6190,9 +6568,13 @@ describe('D3 host restricts the createServer constructor to direct-call position // 14–15 — a direct destructuring call and a renamed destructuring call. { form: 'a direct destructured createServer call', source: `import * as http from 'node:http';\nconst { createServer } = http;\ncreateServer(() => {});` }, { form: 'a direct renamed destructured createServer call', source: `import * as http from 'node:http';\nconst { createServer: mk } = http;\nmk(() => {});` }, - // 16–17 — storing and exporting the CALL RESULT (the Server object, capability NONE). + // 16 — storing the CALL RESULT in a confined const (the Server object, capability NONE). { form: 'storing the createServer call RESULT', source: `import http from 'node:http';\nconst server = http.createServer(() => {});\nvoid server;` }, - { form: 'exporting the createServer call RESULT', source: `import http from 'node:http';\nexport const server = http.createServer(() => {});` }, + // (17 — EXPORTING the call result was formerly allowed here as a capability-NONE value; it now + // DENIES under the D3 STRUCTURAL CREATE-SERVER RESULT CONFINEMENT — an exported createServer + // result is an unsupported result position — asserted just below the loop as an ALLOW→DENY flip. + // The createServer-ALIAS policy `isCreateServerSafePosition` still treats it as capability-NONE; + // the denial comes from SOCK, so `exportsHttpCapability` stays false for the same source.) // 18–19 — an unrelated local createServer, and a shadowed parameter createServer. { form: 'an unrelated local object method createServer', source: `const local = {\n createServer() {},\n};\nlocal.createServer();` }, { form: 'a shadowed parameter named createServer', source: `function f(createServer: () => void) {\n createServer();\n}\nvoid f;` }, @@ -6204,6 +6586,13 @@ describe('D3 host restricts the createServer constructor to direct-call position expect(usesOutboundNetwork(source)).toBe(false); }); } + + // The former "exporting the createServer call RESULT" ALLOW row, flipped to DENY by the D3 + // STRUCTURAL CREATE-SERVER RESULT CONFINEMENT (an exported createServer result is not an approved + // result position). Source byte-identical to the relocated `createServerAllow` entry. + it('DENIES exporting the createServer call RESULT (flipped ALLOW→DENY: unsupported result position)', () => { + expect(usesOutboundNetwork(`import http from 'node:http';\nexport const server = http.createServer(() => {});`)).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -6540,10 +6929,9 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'unrelated text[character] indexing', source: `const text = 'abc';\nconst character = 1;\nvoid text[character];` }, { form: 'unrelated object[key] indexing', source: `const object: Record = {};\nconst key = 'a';\nvoid object[key];` }, { form: 'a non-socket ordinary member on an unrelated object', source: `const obj = { value: 1 };\nvoid obj.value;` }, - // Matrix item 17 — the frozen honest boundary stays OUTSIDE the proof; the mechanism must - // NOT be broadened to close it (doing so would need alias/type/whole-program analysis). - { form: 'the frozen alias + runtime-key residual on req (remains allowed)', source: handler(`const r = req;\nconst k = req.url ?? '';\nvoid r[k];`) }, - { form: 'the frozen runtime-computed server[k] residual (remains allowed)', source: wrapperHead + `const k = String(4317);\nvoid server[k];` }, + // Matrix item 17 — the FORMER frozen alias + runtime-key residuals (`const r = req; r[k]`, + // `server[String(4317)]`) are now CLOSED by the D3 STRUCTURAL model and relocated to + // `structuralFlipReject` below (a proven req/server target with a runtime element key DENIES). ]; for (const { form, source } of sockAllow) { it(`allows ${form}`, () => { @@ -6571,19 +6959,11 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } - // --- DDR-B: the frozen runtime-computed boundary stays OUTSIDE the proof — MUST ALLOW. - // A key the binder cannot pin to a static string (a call result, an ambient runtime name) is - // Indeterminate; closing it would need alias/type/whole-program flow, deliberately excluded. --- - const ddrBAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'a genuinely runtime server[runtimeKey] (declared, unresolvable)', source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, - { form: 'a call-result key server[String(4317)] (unresolvable)', source: wrapperHead + `void server[String(4317)];` }, - { form: 'a harmless resolvable non-socket key server["lis" + "ten"]', source: wrapperHead + `void server['lis' + 'ten'];` }, - ]; - for (const { form, source } of ddrBAllow) { - it(`allows ${form}`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - }); - } + // --- DDR-B: the FORMER frozen runtime-computed boundary is now CLOSED by the D3 STRUCTURAL model + // (a proven SERVER with ANY runtime / non-resolved element key DENIES — key policy: runtime key + // DENY on a proven target) and its rows are relocated to `structuralFlipReject` below. Only a + // runtime key on an UNRELATED (unproven) receiver stays outside the proof (see `binderKeyAllow` + // M-rows and the frozen-boundary allow block). --- // --- DDR-B (STATIC-TEMPLATE PARITY): a socket-acquisition KEY spelled as a SUBSTITUTED template // `` server[`o${'n'}`] `` denotes the same static string as `server['on']` / `server['o'+'n']`, @@ -6612,21 +6992,12 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } - // --- DDR-B (STATIC-TEMPLATE PARITY): the frozen indeterminate boundary is UNCHANGED — a template - // with ANY non-bounded-static substitution (runtime/ambient identifier, mutable `let` binding, - // unresolvable call) stays Indeterminate, so on a non-req/res receiver it is NOT flagged. Only a - // fully binder-static template folds. MUST ALLOW. --- - const ddrBTemplateAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'a runtime-substituted server[`o${runtime}n`] (declared, unresolvable)', source: wrapperHead + 'declare const runtime: string;\nvoid server[`o${runtime}n`];' }, - { form: 'a mutable-binding server[`${a}n`] with let a = \'o\'', source: wrapperHead + 'let a = \'o\';\na = \'o\';\nvoid server[`${a}n`];' }, - { form: 'a call-result substitution server[`o${String(1)}`] (unresolvable)', source: wrapperHead + 'void server[`o${String(1)}`];' }, - { form: 'a harmless resolvable non-socket template server[`lis${\'ten\'}`]', source: wrapperHead + 'void server[`lis${\'ten\'}`];' }, - ]; - for (const { form, source } of ddrBTemplateAllow) { - it(`allows ${form}`, () => { - expect(usesOutboundNetwork(source)).toBe(false); - }); - } + // --- DDR-B (STATIC-TEMPLATE PARITY): the template rows on a PROVEN server (`server[`o${runtime}n`]`, + // `server[`lis${'ten'}`]`, …) are now CLOSED by the D3 STRUCTURAL model — a runtime/mutable/ + // call-result template key DENIES (runtime key on a proven target), and even a fully-resolved + // harmless template like `` server[`lis${'ten'}`] `` DENIES because it is a bare READ of a + // non-called member. Relocated to `structuralFlipReject`. The unproven-receiver template + // boundary is unchanged (a template on `globalThis`/an unrelated object still folds by binder). --- // --- ASSIGNMENT-DESTRUCTURING PARITY (SOCK, DELIVERY/REGISTRAR members ONLY): `({ on: register } = // server)` extracts the registrar off `server` exactly like the declaration twin @@ -6664,7 +7035,9 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 const assignDestructureAllow: readonly { readonly form: string; readonly source: string }[] = [ // The accepted D3-CX-CODEX-ASSIGN invariant, asserted adjacently here as a preservation guard. { form: 'the preserved ({ socket: localSocket } = unrelatedObject) capability invariant', source: `const unrelatedObject = { socket: 123 };\nlet localSocket: unknown;\n({ socket: localSocket } = unrelatedObject);\nvoid localSocket;` }, - { form: 'a capability ({ socket: s } = server) stays req/res-bound (NOT delivery-broadened)', source: wrapperHead + `let s: unknown;\n({ socket: s } = server as unknown as { socket: unknown });\nvoid s;` }, + // `({ socket: s } = server)` on a PROVEN server is now CLOSED by the D3 STRUCTURAL model (a + // destructuring READ off a proven target is a non-allowlisted escape) — relocated to + // `structuralFlipReject`. The UNRELATED-receiver capability invariant below is unchanged. { form: 'a capability ({ connection: c } = unrelatedObject) is NOT globally banned', source: `const unrelatedObject = { connection: 1 };\nlet c: unknown;\n({ connection: c } = unrelatedObject);\nvoid c;` }, { form: 'a harmless ({ harmless: x } = arbitraryObject)', source: `const arbitraryObject: Record = {};\nlet x: unknown;\n({ harmless: x } = arbitraryObject);\nvoid x;` }, { form: 'a harmless shorthand ({ method } = arbitraryObject)', source: `const arbitraryObject: Record = {};\nlet method: unknown;\n({ method } = arbitraryObject);\nvoid method;` }, @@ -6728,8 +7101,10 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 const emitAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'an object-literal DATA key { emit: 1 }', source: `const config = { emit: 1 };\nvoid config;` }, { form: "a bare string binding const label = 'emit'", source: `const label = 'emit';\nvoid label;` }, - { form: 'a superstring member server.emitter', source: wrapperHead + `void server.emitter;` }, - { form: 'a superstring member server.emitted', source: wrapperHead + `void server.emitted;` }, + // `server.emitter` / `server.emitted` on a PROVEN server were formerly allowed as superstrings of + // the banned `emit`; under the D3 STRUCTURAL model ANY non-allowlisted member READ of a proven + // server DENIES (name-independently), so both are relocated to `structuralFlipReject`. The + // non-acquisition-position rows below (object-literal data key, string, type signature) stay allowed. { form: 'a type-position emit method signature', source: `interface Sink {\n emit(x: number): void;\n}\ndeclare const s: Sink;\nvoid s;` }, { form: "an unrelated call arg log('emit')", source: `declare function log(m: string): void;\nlog('emit');` }, ]; @@ -6786,11 +7161,13 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 { form: 'Object.defineProperty(config, "socket", …) on an unrelated object', source: `const config = { a: 1 };\nObject.defineProperty(config, 'socket', { value() { return true; } });` }, { form: 'Object.assign({}, harmless)', source: `const harmless = { a: 1 };\nObject.assign({}, harmless);` }, { form: 'Object.assign({}, { emit: 1 })', source: `Object.assign({}, { emit: 1 });` }, - { form: 'Object.assign({}, server) — reading FROM a privileged server (arg0 unprivileged)', source: metaServerHead + `Object.assign({}, server);` }, + // `Object.assign({}, server)` and `Object.freeze(server)` on a PROVEN server were formerly allowed + // (arg0-unprivileged / non-mutating). Under the D3 STRUCTURAL CALLEE RULE a proven target passed + // as ANY argument to a non-approved callee DENIES (the reading/freezing is not claimed — the + // ESCAPE is), so both are relocated to `structuralFlipReject`. Benign UNRELATED-target ops stay allowed. { form: 'Object.freeze(benignObject)', source: `const benignObject = { a: 1 };\nObject.freeze(benignObject);` }, { form: 'Object.seal(benignObject)', source: `const benignObject = { a: 1 };\nObject.seal(benignObject);` }, { form: 'Object.preventExtensions(benignObject)', source: `const benignObject = { a: 1 };\nObject.preventExtensions(benignObject);` }, - { form: 'Object.freeze(server) on a privileged target — non-mutating, outside META_MUTATION_APIS', source: metaServerHead + `Object.freeze(server);` }, { form: 'ordinary benign Object.defineProperty on an unrelated target', source: `const obj = { a: 1 };\nObject.defineProperty(obj, 'x', { value: 1 });\nvoid Object.keys(obj);` }, { form: "res.setHeader('connection', 'close')", source: handler(`res.setHeader('connection', 'close');`) }, { form: "map.get('connection')", source: `declare const map: { get(k: string): unknown };\nvoid map.get('connection');` }, @@ -6812,7 +7189,10 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 // `server.emit` on these servers; only the target-gated meta-mutation is out of reach. --- const metaBoundaryAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: 'Object.defineProperty(getServer(), "emit", …) — arbitrary call-result target', source: H + `declare function getServer(): http.Server;\nObject.defineProperty(getServer(), 'emit', { value() { return true; } });` }, - { form: 'a function-return server (const server = makeServer()) target', source: wrapperHead + `Object.defineProperty(server, 'emit', { value() { return true; } });` }, + // NOTE: the former `const server = makeServer()` (function-return) row is now CLOSED by the D3 + // STRUCTURAL model — `makeServer` is a proven local SERVER FACTORY, so its result is a proven + // target and the meta-mutation DENIES. Relocated to `structuralFlipReject`. `getServer()` above + // stays outside the proof: it is an AMBIENT `declare function` (no inspectable body), not a factory. ]; for (const { form, source } of metaBoundaryAllow) { it(`leaves outside the finite proof (documented boundary): ${form}`, () => { @@ -6888,11 +7268,12 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 // identity — a harmless key is NEVER flipped by a shadowing socket-named sibling const // (M16 exercises binder identity in the ALLOW direction). MUST ALLOW. --- const binderKeyAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: "M11: server[runtimeKey] on an unrelated receiver stays outside the proof", source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, - { form: "M12: server[String(4317)] stays outside the static proof", source: wrapperHead + `void server[String(4317)];` }, + // M11 (`server[runtimeKey]`), M12 (`server[String(4317)]`), M15 (`server['listen']` read), + // and M16 (a harmless resolved key read) were formerly allowed on a PROVEN server. Under the D3 + // STRUCTURAL model a runtime element key on a proven target DENIES (M11/M12), and a bare READ of a + // non-called member — even the known method `listen` — DENIES (M15/M16, method extraction). + // Relocated to `structuralFlipReject`. M14 stays: `req['method']` is a demonstrated REQUEST read. { form: "M14: req['method'] stays allowed", source: handler(`void req['method'];`) }, - { form: "M15: harmless static server['listen'] stays allowed", source: wrapperHead + `void server['listen'];` }, - { form: "M16: a harmless key is not flipped by a shadowing socket-named sibling const", source: wrapperHead + `const k = 'listen';\nfunction unrelated(): void {\n const k = 'on';\n void k;\n}\nvoid server[k];\nvoid unrelated;` }, ]; for (const { form, source } of binderKeyAllow) { it(`allows ${form}`, () => { @@ -7388,10 +7769,11 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 // ---- MECH-2 (8, preserve): a static harmless key on the chain, and a chain NOT rooted at a // tracked req/res param, keep their disposition (no alias / value flow). ---- const staticChainAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: "a static harmless key on the chain res.req['method']", source: handler(`void (res as any).req['method'];`) }, - { form: 'a dotted harmless chain res.req.method', source: handler(`void (res as any).req.method;`) }, + // The chains ROOTED at a proven `res` (`res.req['method']`, `res.req.method`, + // `res.getHeader('x')[rk]`) are now CLOSED by the D3 STRUCTURAL model — `.req` / `.getHeader` + // are non-allowlisted RESPONSE members, so the very first hop DENIES (member-chain closure). + // Relocated to `structuralFlipReject`. A chain rooted at an UNRELATED object stays outside the proof. { form: 'a chain rooted at an unrelated object unrelated.req[runtimeKey]', source: RK + `const unrelated: any = {};\nvoid unrelated.req[rk];` }, - { form: 'a chain broken by a call res.getHeader("x")[runtimeKey] (frozen callee boundary)', source: handler(RK + `void (res as any).getHeader('x')[rk];`) }, ]; for (const { form, source } of staticChainAllow) { it(`allows ${form}`, () => { @@ -7433,15 +7815,17 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 const reflectiveAllow: readonly { readonly form: string; readonly source: string }[] = [ { form: "Reflect.get(local, 'fetch') (non-socket name, non-global receiver)", source: `const local = { fetch() {} };\nReflect.get(local, 'fetch')();` }, { form: "Reflect.get(globalThis, 'crypto') (non-socket, non-network name)", source: `void Reflect.get(globalThis, 'crypto');` }, - { form: "a user-shadowed Reflect.get(req, 'socket') (ordinary object, not the built-in)", source: handler(`const Reflect = { get(_t: unknown, _k: string): unknown { return 1; } };\nvoid Reflect.get(req, 'socket');`) }, - { form: "a user-shadowed Object.getOwnPropertyDescriptor(req, 'socket')", source: handler(`const Object = { getOwnPropertyDescriptor(_t: unknown, _k: string): unknown { return 1; } };\nvoid Object.getOwnPropertyDescriptor(req, 'socket');`) }, + // A user-SHADOWED `Reflect.get`/`Object.getOwnPropertyDescriptor`, and `Reflect.has`/`Reflect.ownKeys` + // (not the approved free reflective-READ shape), pass a PROVEN `req` to a non-approved callee — now + // CLOSED by the D3 STRUCTURAL CALLEE RULE (an escape to any callee outside the free Reflect.get read + // shape / an approved local function DENIES). Relocated to `structuralFlipReject`. The genuine free + // `Reflect.get(req, 'method')` with a harmless resolved key STAYS allowed (key policy), as do all + // UNPROVEN-target rows. { form: "res.setHeader('connection', 'close') (an ordinary method call, not a reflective read)", source: handler(`res.setHeader('connection', 'close');`) }, { form: "map.get('connection') (a non-builtin .get)", source: `const map = new Map();\nvoid map.get('connection');` }, { form: "log('socket') (a plain call with a socket-named string)", source: `declare function log(s: string): void;\nlog('socket');` }, { form: "Reflect.get(req, 'method') (harmless resolved key)", source: handler(`void Reflect.get(req, 'method');`) }, { form: 'Reflect.get(unrelated, runtimeKey) (indeterminate key on an UNTRACKED target)', source: RK + `const unrelated: Record = {};\nvoid Reflect.get(unrelated, rk);` }, - { form: "Reflect.has(req, 'socket') (not an acquisition API)", source: handler(`void Reflect.has(req, 'socket');`) }, - { form: "Reflect.ownKeys(req) (no property-key argument)", source: handler(`void Reflect.ownKeys(req);`) }, ]; for (const { form, source } of reflectiveAllow) { it(`allows ${form}`, () => { @@ -7508,18 +7892,13 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 // ---- OUTSIDE_BOUNDARY (frozen — deliberately NOT closed by this repair; asserted so the // mechanism is provably not broadened into alias / value-flow analysis). ---- const frozenOutsideBoundaryAllow: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'the alias + runtime key const r = req; r[k] (frozen)', source: handler(RK + `const r = req;\nvoid r[rk];`) }, - { form: 'the array-wrapped [req][0][k] (frozen)', source: handler(RK + `void [req][0][rk];`) }, - { form: 'the spread-copy ({ ...req })[k] (frozen)', source: handler(RK + `void ({ ...req } as any)[rk];`) }, - { form: 'Object.values(req) (frozen)', source: handler(`void Object.values(req);`) }, - { form: 'Object.entries(req) (frozen)', source: handler(`void Object.entries(req);`) }, - { form: 'Object.assign({}, req) (frozen)', source: handler(`void Object.assign({}, req);`) }, - { form: 'Object.getOwnPropertyDescriptors(req) (frozen — not the recognized single-key API)', source: handler(`void Object.getOwnPropertyDescriptors(req);`) }, - { form: "an aliased Reflect const R = Reflect; R.get(req, 'socket') (frozen)", source: handler(`const R = Reflect;\nvoid R.get(req, 'socket');`) }, - { form: "a global-object member spelling globalThis.Reflect.get(req, 'socket') (frozen — the aliased-Reflect indirection class, same boundary as NET F1)", source: handler(`void (globalThis as any).Reflect.get(req, 'socket');`) }, - { form: "a call-chained Reflect.get.call(Reflect, req, 'socket') (frozen — call/apply/bind indirection)", source: handler(`void Reflect.get.call(Reflect, req, 'socket');`) }, - { form: 'a renamed destructuring alias ({ req: r }) => r[k] (frozen — the req/res alias family)', source: H + RK + `http.createServer((_req: any, { req: r }: any) => {\n void r[rk];\n});` }, - { form: 'the runtime-computed server[String(4317)] (frozen)', source: wrapperHead + `void server[String(4317)];` }, + // Most former frozen-boundary rows are now CLOSED by the D3 STRUCTURAL model and relocated to + // `structuralFlipReject`: `const r = req; r[rk]` (proven alias + runtime key), `[req][0][rk]` / + // `({ ...req })[rk]` (container escapes), `Object.values/entries/assign/getOwnPropertyDescriptors(req)` + // and the aliased/global/call-chained `Reflect.get(req, …)` forms (proven target passed to a + // non-approved callee), and `server[String(4317)]` (runtime key). What STAYS outside the proof is + // ONLY the nested-destructuring RENAME whose local binding this pass does not track as a root: + { form: 'a renamed destructuring alias ({ req: r }) => r[k] (still outside — nested-rename binding, not a tracked root)', source: H + RK + `http.createServer((_req: any, { req: r }: any) => {\n void r[rk];\n});` }, ]; for (const { form, source } of frozenOutsideBoundaryAllow) { it(`allows (frozen, outside the boundary) ${form}`, () => { @@ -7790,6 +8169,197 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 expect(usesOutboundNetwork(source)).toBe(false); }); } + + // ===================================================================================== + // D3 STRUCTURAL REPAIR — ALLOW→DENY FLIPS. Each row below was a previously documented FROZEN / + // residual ALLOW that the receiver-independent NAME ban left open on a PROVEN target. The D3 + // STRUCTURAL provenance/position model (MECH-D3) now CLOSES every one of them structurally + // (PROVEN TARGET + NON-ALLOWLISTED OPERATION = DENY), so their expectation flips to DENY. The + // SOURCES are byte-identical to the relocated entries (nothing is deleted; coverage is preserved + // with an inverted assertion, exactly like the earlier `optionsBoundaryReject` O1–O6 / L1–L2 + // flips). NO historical DENY is weakened — only these ALLOW residuals move. --- + // ===================================================================================== + const structuralFlipReject: readonly { readonly reason: string; readonly form: string; readonly source: string }[] = [ + // Runtime / non-resolved element key on a proven target (key policy: runtime key DENY). + { reason: 'proven-req alias + runtime key', form: "sockAllow: const r = req; const k = req.url ?? ''; r[k]", source: handler(`const r = req;\nconst k = req.url ?? '';\nvoid r[k];`) }, + { reason: 'proven-server runtime key', form: 'sockAllow: const k = String(4317); server[k]', source: wrapperHead + `const k = String(4317);\nvoid server[k];` }, + { reason: 'proven-server runtime key', form: 'ddrBAllow: server[runtimeKey] (declared)', source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, + { reason: 'proven-server runtime key', form: 'ddrBAllow: server[String(4317)]', source: wrapperHead + `void server[String(4317)];` }, + { reason: 'proven-server non-called member read', form: "ddrBAllow: server['lis' + 'ten'] (resolves to listen, read)", source: wrapperHead + `void server['lis' + 'ten'];` }, + { reason: 'proven-server runtime key', form: 'ddrBTemplateAllow: server[`o${runtime}n`]', source: wrapperHead + 'declare const runtime: string;\nvoid server[`o${runtime}n`];' }, + { reason: 'proven-server runtime key', form: "ddrBTemplateAllow: server[`${a}n`] with let a", source: wrapperHead + 'let a = \'o\';\na = \'o\';\nvoid server[`${a}n`];' }, + { reason: 'proven-server runtime key', form: 'ddrBTemplateAllow: server[`o${String(1)}`]', source: wrapperHead + 'void server[`o${String(1)}`];' }, + { reason: 'proven-server non-called member read', form: "ddrBTemplateAllow: server[`lis${'ten'}`] (resolves to listen, read)", source: wrapperHead + 'void server[`lis${\'ten\'}`];' }, + // Destructuring READ off a proven target (non-allowlisted extraction / escape). + { reason: 'proven-server destructuring read', form: 'assignDestructureAllow: ({ socket: s } = server)', source: wrapperHead + `let s: unknown;\n({ socket: s } = server as unknown as { socket: unknown });\nvoid s;` }, + // Non-allowlisted member READ of a proven server (name-independent; formerly allowed as superstrings). + { reason: 'proven-server unknown-member read', form: 'emitAllow: server.emitter', source: wrapperHead + `void server.emitter;` }, + { reason: 'proven-server unknown-member read', form: 'emitAllow: server.emitted', source: wrapperHead + `void server.emitted;` }, + // Proven target passed as an argument to a non-approved callee (CALLEE RULE escape). + { reason: 'proven-server escape into Object.assign', form: 'metaAllow: Object.assign({}, server)', source: metaServerHead + `Object.assign({}, server);` }, + { reason: 'proven-server escape into Object.freeze', form: 'metaAllow: Object.freeze(server)', source: metaServerHead + `Object.freeze(server);` }, + // Factory-result provenance: makeServer() is a proven SERVER factory. + { reason: 'proven factory-result meta-mutation', form: 'metaBoundaryAllow: const server = makeServer(); Object.defineProperty(server, ...)', source: wrapperHead + `Object.defineProperty(server, 'emit', { value() { return true; } });` }, + // Runtime key / method-extraction on a proven server (binder-key matrix). + { reason: 'proven-server runtime key', form: 'binderKeyAllow M11: server[runtimeKey]', source: wrapperHead + `declare const runtimeKey: string;\nvoid server[runtimeKey];` }, + { reason: 'proven-server runtime key', form: 'binderKeyAllow M12: server[String(4317)]', source: wrapperHead + `void server[String(4317)];` }, + { reason: 'proven-server method extraction', form: "binderKeyAllow M15: server['listen'] (read, not called)", source: wrapperHead + `void server['listen'];` }, + { reason: 'proven-server method extraction', form: "binderKeyAllow M16: server[k] with const k = 'listen' (read)", source: wrapperHead + `const k = 'listen';\nfunction unrelated(): void {\n const k = 'on';\n void k;\n}\nvoid server[k];\nvoid unrelated;` }, + // Member-chain closure: the FIRST hop off a proven res (`.req` / `.getHeader`) is non-allowlisted. + { reason: 'proven-res unknown-member chain', form: "staticChainAllow: res.req['method']", source: handler(`void (res as any).req['method'];`) }, + { reason: 'proven-res unknown-member chain', form: 'staticChainAllow: res.req.method', source: handler(`void (res as any).req.method;`) }, + { reason: 'proven-res unknown-member call', form: "staticChainAllow: res.getHeader('x')[rk]", source: handler(RK + `void (res as any).getHeader('x')[rk];`) }, + // Proven req passed to a non-approved callee (shadowed/aliased Reflect, non-read reflective APIs). + { reason: 'proven-req escape into user callee', form: "reflectiveAllow: user-shadowed Reflect.get(req, 'socket')", source: handler(`const Reflect = { get(_t: unknown, _k: string): unknown { return 1; } };\nvoid Reflect.get(req, 'socket');`) }, + { reason: 'proven-req escape into user callee', form: "reflectiveAllow: user-shadowed Object.getOwnPropertyDescriptor(req, 'socket')", source: handler(`const Object = { getOwnPropertyDescriptor(_t: unknown, _k: string): unknown { return 1; } };\nvoid Object.getOwnPropertyDescriptor(req, 'socket');`) }, + { reason: 'proven-req escape into non-read Reflect API', form: "reflectiveAllow: Reflect.has(req, 'socket')", source: handler(`void Reflect.has(req, 'socket');`) }, + { reason: 'proven-req escape into non-read Reflect API', form: 'reflectiveAllow: Reflect.ownKeys(req)', source: handler(`void Reflect.ownKeys(req);`) }, + // Former frozen-outside-boundary residuals on a proven req/server, now closed. + { reason: 'proven-req alias + runtime key', form: 'frozen: const r = req; r[rk]', source: handler(RK + `const r = req;\nvoid r[rk];`) }, + { reason: 'proven-req container escape', form: 'frozen: [req][0][rk]', source: handler(RK + `void [req][0][rk];`) }, + { reason: 'proven-req spread escape', form: 'frozen: ({ ...req })[rk]', source: handler(RK + `void ({ ...req } as any)[rk];`) }, + { reason: 'proven-req escape into callee', form: 'frozen: Object.values(req)', source: handler(`void Object.values(req);`) }, + { reason: 'proven-req escape into callee', form: 'frozen: Object.entries(req)', source: handler(`void Object.entries(req);`) }, + { reason: 'proven-req escape into callee', form: 'frozen: Object.assign({}, req)', source: handler(`void Object.assign({}, req);`) }, + { reason: 'proven-req escape into callee', form: 'frozen: Object.getOwnPropertyDescriptors(req)', source: handler(`void Object.getOwnPropertyDescriptors(req);`) }, + { reason: 'proven-req escape into aliased Reflect', form: "frozen: const R = Reflect; R.get(req, 'socket')", source: handler(`const R = Reflect;\nvoid R.get(req, 'socket');`) }, + { reason: 'proven-req escape into global Reflect', form: "frozen: globalThis.Reflect.get(req, 'socket')", source: handler(`void (globalThis as any).Reflect.get(req, 'socket');`) }, + { reason: 'proven-req escape into call-chained Reflect', form: "frozen: Reflect.get.call(Reflect, req, 'socket')", source: handler(`void Reflect.get.call(Reflect, req, 'socket');`) }, + { reason: 'proven-server runtime key', form: 'frozen: server[String(4317)]', source: wrapperHead + `void server[String(4317)];` }, + ]; + for (const { reason, form, source } of structuralFlipReject) { + it(`rejects (flipped ALLOW→DENY: ${reason}) ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ===================================================================================== + // D3 STRUCTURAL provenance/position model (MECH-D3) — the AUTHORIZED structural repair. + // + // INVARIANT: PROVEN PRIVILEGED TARGET + NON-ALLOWLISTED OPERATION = DENY. + // + // This replaces witness-by-witness mutation detection with ONE positive-operation model: + // a proven SERVER / REQUEST / RESPONSE may occur ONLY in its finite approved operations; + // EVERY other operation denies STRUCTURALLY, without the detector naming the dangerous + // member. The witnesses below therefore use an UNKNOWN member `futureMemberX` (a name the + // detector logic must not contain): if `futureMemberX` denies exactly as `emit` does, the + // rule is provably key/name-independent (criterion #3). Categories, not permutations — + // each category is spelled dotted / static-computed / folded / template where meaningful. + // + // SERVER roots exercised: a DIRECT `const server = http.createServer(...)` (metaServerHead), + // a local-FACTORY result `const server = makeServer()` (wrapperHead), and the bare + // createServer call/new result. REQUEST/RESPONSE roots: the createServer listener + // parameters 0/1 (handler(...)). Provenance flows through unique non-exported `const` + // aliases and one-hop local-function parameter propagation only. + // ===================================================================================== + describe('D3 STRUCTURAL provenance/position model (PROVEN TARGET + NON-ALLOWLISTED OPERATION = DENY)', () => { + const S = metaServerHead; // `const server = http.createServer(() => {})` — a proven SERVER binding. + + // ---- MUST-DENY category matrix (proven target, non-allowlisted operation). ---- + const structuralDeny: readonly { readonly category: string; readonly form: string; readonly source: string }[] = [ + // SERVER — direct unknown-member WRITE (dotted / static-computed / template / folded key). + { category: 'SERVER direct unknown-member write', form: 'dotted server.futureMemberX = fn', source: S + `server.futureMemberX = function () { return true; };` }, + { category: 'SERVER direct unknown-member write', form: "static-computed server['futureMemberX'] = fn", source: S + `server['futureMemberX'] = function () { return true; };` }, + { category: 'SERVER direct unknown-member write', form: 'template server[`futureMemberX`] = fn', source: S + 'server[`futureMemberX`] = function () { return true; };' }, + { category: 'SERVER direct unknown-member write', form: "folded-key const k = 'futureMemberX'; server[k] = fn", source: S + `const k = 'futureMemberX';\nserver[k] = function () { return true; };` }, + // SERVER — compound / update / delete. + { category: 'SERVER compound/update/delete', form: 'compound server.futureMemberX += 1', source: S + `(server as any).futureMemberX += 1;` }, + { category: 'SERVER compound/update/delete', form: 'update server.futureMemberX++', source: S + `(server as any).futureMemberX++;` }, + { category: 'SERVER compound/update/delete', form: 'prefix update ++server.futureMemberX', source: S + `++(server as any).futureMemberX;` }, + { category: 'SERVER compound/update/delete', form: 'delete server.futureMemberX', source: S + `delete (server as any).futureMemberX;` }, + // SERVER — unknown-member READ, including a method EXTRACTION (a known method read, uncalled). + { category: 'SERVER unknown-member read', form: 'dotted read void server.futureMemberX', source: S + `void server.futureMemberX;` }, + { category: 'SERVER unknown-member read', form: "static-computed read void server['futureMemberX']", source: S + `void server['futureMemberX'];` }, + { category: 'SERVER unknown-member read', form: 'template read void server[`futureMemberX`]', source: S + 'void server[`futureMemberX`];' }, + { category: 'SERVER unknown-member read', form: "folded-key read const k = 'futureMemberX'; void server[k]", source: S + `const k = 'futureMemberX';\nvoid server[k];` }, + { category: 'SERVER unknown-member read', form: 'method EXTRACTION void server.listen (uncalled known method)', source: S + `void server.listen;` }, + { category: 'SERVER unknown-member read', form: "method EXTRACTION const m = server['close']", source: S + `const m = server['close'];\nvoid m;` }, + // SERVER — unknown-member CALL. + { category: 'SERVER unknown-member call', form: 'server.futureMemberX()', source: S + `server.futureMemberX();` }, + { category: 'SERVER unknown-member call', form: "server['futureMemberX']()", source: S + `server['futureMemberX']();` }, + // SERVER — passed to an unknown / indirect callee (mutator never identified). + { category: 'SERVER passed to unknown/indirect callee', form: 'declared sink(server)', source: S + `declare function sink(x: unknown): void;\nsink(server);` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'const define = Object.defineProperty; define(server, ...)', source: S + `const define = Object.defineProperty;\ndefine(server, 'futureMemberX', { value: 1 });` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'const { defineProperty } = Object; defineProperty(server, ...)', source: S + `const { defineProperty } = Object;\ndefineProperty(server, 'futureMemberX', { value: 1 });` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'Object.defineProperty.call(Object, server, ...)', source: S + `Object.defineProperty.call(Object, server, 'futureMemberX', { value: 1 });` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'Reflect.apply(sink, null, [server]) (server in array arg)', source: S + `declare function sink(x: unknown): void;\nReflect.apply(sink, null, [server]);` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'comma-callee (0, sink)(server)', source: S + `declare function sink(x: unknown): void;\n(0, sink)(server);` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'element-callee [sink][0](server)', source: S + `declare function sink(x: unknown): void;\n[sink][0](server);` }, + { category: 'SERVER passed to unknown/indirect callee', form: 'globalThis.Object.defineProperty(server, ...)', source: S + `globalThis.Object.defineProperty(server, 'futureMemberX', { value: 1 });` }, + // SERVER — member-chain mutation (closes at the first non-allowlisted hop). + { category: 'SERVER chain mutation', form: 'server.futureMemberX.deeper = fn', source: S + `(server as any).futureMemberX.deeper = function () { return true; };` }, + { category: 'SERVER chain mutation', form: 'server.close.call = fn (mutation off a known method value)', source: S + `(server.close as any).call = 1;` }, + // SERVER — container / value escape. + { category: 'SERVER container/value escape', form: 'array literal [server]', source: S + `const holder = [server];\nvoid holder;` }, + { category: 'SERVER container/value escape', form: 'object value { s: server }', source: S + `const holder = { s: server };\nvoid holder;` }, + { category: 'SERVER container/value escape', form: 'shorthand object value { server }', source: S + `const holder = { server };\nvoid holder;` }, + // SERVER — unsupported createServer RESULT shapes. + { category: 'SERVER unsupported createServer result shape', form: 'let s = http.createServer(...)', source: H + `let s = http.createServer(() => {});\nvoid s;` }, + { category: 'SERVER unsupported createServer result shape', form: 'var s = http.createServer(...)', source: H + `var s = http.createServer(() => {});\nvoid s;` }, + { category: 'SERVER unsupported createServer result shape', form: 'array [http.createServer(...)]', source: H + `const holder = [http.createServer(() => {})];\nvoid holder;` }, + { category: 'SERVER unsupported createServer result shape', form: 'object { s: http.createServer(...) }', source: H + `const holder = { s: http.createServer(() => {}) };\nvoid holder;` }, + { category: 'SERVER unsupported createServer result shape', form: 'call argument sink(http.createServer(...))', source: H + `declare function sink(x: unknown): void;\nsink(http.createServer(() => {}));` }, + { category: 'SERVER unsupported createServer result shape', form: 'destructuring const { listen } = http.createServer(...)', source: H + `const { listen } = http.createServer(() => {});\nvoid listen;` }, + { category: 'SERVER unsupported createServer result shape', form: 'exported result export const srv = http.createServer(...)', source: H + `export const srv = http.createServer(() => {});` }, + { category: 'SERVER unsupported createServer result shape', form: 'new-result let s = new http.createServer(...)', source: H + `let s = new http.createServer(() => {});\nvoid s;` }, + // REQUEST — unknown read / write / escape. + { category: 'REQUEST unknown-member read', form: 'dotted read void req.futureMemberX', source: handler(`void req.futureMemberX;`) }, + { category: 'REQUEST unknown-member read', form: "static-computed void req['futureMemberX']", source: handler(`void req['futureMemberX'];`) }, + { category: 'REQUEST unknown-member write', form: 'req.futureMemberX = 1', source: handler(`(req as any).futureMemberX = 1;`) }, + { category: 'REQUEST unknown-member write', form: 'req.method = "X" (write to a KNOWN read member)', source: handler(`(req as any).method = 'X';`) }, + { category: 'REQUEST escape', form: 'sink(req)', source: handler(`declare function sink(x: unknown): void;\nsink(req);`) }, + { category: 'REQUEST escape', form: 'array [req]', source: handler(`const holder = [req];\nvoid holder;`) }, + // RESPONSE — unknown read / call, non-literal write, escape. + { category: 'RESPONSE unknown-member read', form: 'dotted read void res.futureMemberX', source: handler(`void res.futureMemberX;`) }, + { category: 'RESPONSE unknown-member call', form: 'res.futureMemberX()', source: handler(`res.futureMemberX();` ) }, + { category: 'RESPONSE unknown-member call', form: 'res.writeHead(200) (a non-allowlisted method call)', source: handler(`(res as any).writeHead(200);`) }, + { category: 'RESPONSE non-literal write', form: 'res.statusCode = code (non-literal RHS)', source: handler(`declare const code: number;\nres.statusCode = code;`) }, + { category: 'RESPONSE non-literal write', form: 'res.statusCode = req.method ? 200 : 404', source: handler(`res.statusCode = req.method === 'GET' ? 200 : 404;`) }, + { category: 'RESPONSE escape', form: 'sink(res)', source: handler(`declare function sink(x: unknown): void;\nsink(res);`) }, + // this-based privileged access — whole-file ThisExpression reservation. + { category: 'this-based privileged access', form: 'a bare ThisExpression in the scanned file', source: H + `void (function (this: unknown) {\n return (this as any).futureMemberX;\n});` }, + { category: 'this-based privileged access', form: 'a listener reaching this', source: H + `http.createServer(function (this: any) {\n void this.futureMemberX;\n});` }, + // factory-result provenance — a local factory return is a proven SERVER. + { category: 'factory-result provenance', form: 'wrapperHead server (makeServer() result) misuse', source: wrapperHead + `server.futureMemberX = 1;` }, + { category: 'factory-result provenance', form: 'inline factory then misuse', source: H + `function make() {\n return http.createServer(() => {});\n}\nconst s = make();\ns.futureMemberX = 1;` }, + { category: 'factory-result provenance', form: 'arrow factory then misuse', source: H + `const make = () => http.createServer(() => {});\nconst s = make();\nvoid s.futureMemberX;` }, + // propagated-parameter provenance — a proven target into a local function propagates. + { category: 'propagated-parameter provenance', form: 'SERVER into local use(s) then s misuse', source: S + `function use(s: any): void {\n s.futureMemberX = 1;\n}\nuse(server);` }, + { category: 'propagated-parameter provenance', form: 'RESPONSE into local leak(r) then r misuse', source: H + `function leak(r: any): void {\n void r.futureMemberX;\n}\nhttp.createServer((req: any, res: any) => {\n leak(res);\n});` }, + { category: 'propagated-parameter provenance', form: 'REQUEST into local grab(r) then r escape', source: H + `declare function sink(x: unknown): void;\nfunction grab(r: any): void {\n sink(r);\n}\nhttp.createServer((req: any) => {\n grab(req);\n});` }, + // two-hop const-alias provenance. + { category: 'two-hop const-alias provenance', form: 'SERVER const s2 = server; const s3 = s2; s3 misuse', source: S + `const s2 = server;\nconst s3 = s2;\ns3.futureMemberX = 1;` }, + { category: 'two-hop const-alias provenance', form: 'REQUEST const r = req; const r2 = r; r2 misuse', source: handler(`const r = req;\nconst r2 = r;\nvoid r2.futureMemberX;`) }, + ]; + for (const { category, form, source } of structuralDeny) { + it(`DENIES [${category}] ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- MUST-ALLOW preservation under the new mechanism: the real host's finite operations. ---- + const structuralAllow: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'SERVER .listen(...) on a direct const binding', source: S + `server.listen(4317, '127.0.0.1');` }, + { form: 'SERVER .close(...) on a direct const binding', source: S + `server.close();` }, + { form: 'SERVER .listen(...) on a factory result', source: wrapperHead + `server.listen(4317, '127.0.0.1');` }, + { form: 'SERVER neutral expression-statement position', source: S + `server;` }, + { form: 'SERVER neutral void position', source: S + `void server;` }, + { form: 'SERVER two-hop const alias then .listen(...)', source: S + `const s2 = server;\nconst s3 = s2;\ns3.listen(4317);` }, + { form: 'SERVER factory-return position (return http.createServer(...))', source: H + `function make(): http.Server {\n return http.createServer(() => {});\n}\nvoid make;` }, + { form: 'REQUEST .method read', source: handler(`const m = req.method ?? '';\nvoid m;`) }, + { form: 'REQUEST .url read', source: handler(`const u = req.url ?? '';\nvoid u;`) }, + { form: "REQUEST static-computed req['method'] read", source: handler(`void req['method'];`) }, + { form: 'RESPONSE the full demonstrated surface', source: handler(`res.statusCode = 200;\nres.setHeader('X', 'Y');\nres.end('ok');`) }, + { form: 'RESPONSE statusCode = numeric literal 405', source: handler(`res.statusCode = 405;`) }, + { form: 'RESPONSE propagation through a local applySecurityHeaders(res)', source: H + `function applySecurityHeaders(r: http.ServerResponse): void {\n r.setHeader('X', 'Y');\n}\nhttp.createServer((req: http.IncomingMessage, res: http.ServerResponse): void => {\n applySecurityHeaders(res);\n res.statusCode = 200;\n res.end(req.method ?? '');\n});` }, + { form: 'the exact real-host createCockpitServer shape', source: H + `function applySecurityHeaders(response: http.ServerResponse): void {\n response.setHeader('Content-Security-Policy', "default-src 'none'");\n}\nfunction pathOf(url: string): string {\n const q = url.indexOf('?');\n return q === -1 ? url : url.slice(0, q);\n}\nfunction createCockpitServer(): http.Server {\n return http.createServer((request: http.IncomingMessage, response: http.ServerResponse): void => {\n applySecurityHeaders(response);\n const method = request.method ?? '';\n if (method !== 'GET') {\n response.statusCode = 405;\n response.setHeader('Allow', 'GET');\n response.end('nope');\n return;\n }\n const path = pathOf(request.url ?? '');\n void path;\n response.statusCode = 200;\n response.end('ok');\n });\n}\nconst server = createCockpitServer();\nserver.listen(4317, '127.0.0.1');` }, + ]; + for (const { form, source } of structuralAllow) { + it(`ALLOWS ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + }); }); // --------------------------------------------------------------------------- From f5b7c3c97426f29a629e31fffd79636a44ff9ce6 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 2 Sep 2026 15:36:27 +0200 Subject: [PATCH 35/35] fix(cockpit): make provenance propagation fail closed --- tests/cockpit-host/purity.test.ts | 147 ++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index a4ee2fa..ff55ee9 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -2171,6 +2171,18 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou return ts.isArrowFunction(init) || ts.isFunctionExpression(init) ? init : null; }; + // SHARED PROPAGATION ELIGIBILITY (the single source of truth for BOTH phases). The ONLY parameter + // shape into which Phase A propagates a proven authority class, and therefore the ONLY shape at + // which Phase B may allow a proven argument to escape into a local callee: a PRESENT, NON-REST, + // plain-IDENTIFIER parameter. A rest parameter, an object/array destructuring pattern, or a missing + // parameter (fewer parameters than arguments) is UNSUPPORTED — it never receives the class in + // Phase A, so allowing the call would let the argument escape the structural policy. Both phases + // consult this rule, so the Phase-A propagation set and the Phase-B allow set cannot disagree: a + // call is NOT permitted merely because `resolveLocalFunction` succeeds. Returns the parameter's + // binding identifier when supported (Phase A keys provenance by its symbol), else null. + const supportedPropagationParamName = (param: ts.ParameterDeclaration | undefined): ts.Identifier | null => + param !== undefined && param.dotDotDotToken === undefined && ts.isIdentifier(param.name) ? param.name : null; + // Whether a VariableDeclaration is a UNIQUE NON-EXPORTED `const` with an identifier name — the // sole approved alias / createServer-result binding shape. A `let`/`var`, an exported binding, or // a destructuring name is NOT confined and denies (an unsupported result shape / identity escape). @@ -2287,6 +2299,15 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou }; // ---- Phase A — bounded fixpoint over provenance (roots, aliases, factories, propagation). ---- + // TERMINATION: provenance facts (a symbol gaining an authority class; a node becoming a factory) + // are added MONOTONICALLY and never removed, over a FINITE symbol × {SERVER,REQUEST,RESPONSE} + // lattice, so the loop strictly ascends and must reach a fixed point in finitely many steps; + // `D3_ITERATION_CAP` bounds the work regardless. The loop has an EXPLICIT terminal state: + // CONVERGED (an iteration adds no fact → `break` with `converged = true`) or EXHAUSTED (the + // cap is consumed while a fact was still added on the final iteration → `converged` stays + // false). A reverse-ordered forwarding chain advances one authority hop per pass, so a chain + // longer than the cap EXHAUSTS with its deepest parameter still unclassified. + let converged = false; for (let iter = 0; iter < D3_ITERATION_CAP; iter++) { // A holder object (not a bare `let`), so the flag mutated inside the nested walk callbacks is // read as `boolean` after those calls return — a plain `let` would be flow-narrowed to its @@ -2338,9 +2359,10 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou if (ts.isSpreadElement(arg)) return; const classes = classesOfValue(arg); if (classes.length === 0) return; - const param = fn.parameters[index]; - if (param === undefined || param.dotDotDotToken !== undefined || !ts.isIdentifier(param.name)) return; - const symbol = checker.getSymbolAtLocation(param.name); + // Propagate ONLY into a SUPPORTED parameter shape (the same rule Phase B allows on). + const name = supportedPropagationParamName(fn.parameters[index]); + if (name === null) return; // rest / destructuring / missing parameter: no propagation + const symbol = checker.getSymbolAtLocation(name); for (const cls of classes) if (addClass(symbol, cls)) flags.changed = true; }); } @@ -2348,8 +2370,15 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou ts.forEachChild(n, walkPropagation); }; ts.forEachChild(sourceFile, walkPropagation); - if (!flags.changed) break; + if (!flags.changed) { + converged = true; + break; + } } + // CONVERGENCE GATE (F2): if the fixpoint EXHAUSTED the cap while still adding provenance, the map + // is known-partial — a privileged parameter may remain unclassified and its capability read + // would escape Phase B — so fail CLOSED rather than classify against an incomplete map. + if (!converged) found = true; // ---- Phase B — classify every USE of a proven target by its structural POSITION. ---- const memberReadAllowed = (name: string, cls: D3Class): boolean => @@ -2440,9 +2469,17 @@ const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.Sou const key = sockResolveKey(keyArg, checker, sockMemo); return key.kind === 'resolved' && memberReadAllowed(key.value, cls); } - // (2) an approved propagating local function (the parameter was tainted in Phase A) - if (resolveLocalFunction(p.expression) !== null) return true; - return false; // any other callee: DENY (Object.defineProperty, aliased mutator, comma/element callee, …) + // (2) an approved propagating local function — allowed ONLY if the corresponding parameter + // position is a SUPPORTED shape (the identical `supportedPropagationParamName` rule Phase A + // propagates on). A rest / destructuring / missing parameter never carried the class in + // Phase A, so letting the argument escape here would defeat the structural policy: DENY. + // A call is NEVER allowed merely because the callee resolves locally. + const fn = resolveLocalFunction(p.expression); + if (fn !== null) { + const argIndex = p.arguments.indexOf(w as ts.Expression); + if (argIndex >= 0 && supportedPropagationParamName(fn.parameters[argIndex]) !== null) return true; + } + return false; // any other callee / unsupported parameter: DENY (Object.defineProperty, aliased mutator, comma/element callee, rest/destructuring parameter, …) } if (ts.isNewExpression(p) && (p.arguments ?? []).some((a) => a === w)) return false; return false; // container element / object value / spread / export / any other escape @@ -8360,6 +8397,102 @@ describe('D3 host enforces the final bounded socket-capability source policy (D3 }); } }); + + // ===================================================================================== + // PR #64 — PROVENANCE PROPAGATION SOUNDNESS + CONVERGENCE (Codex F1 + F2). ONE structural + // family: bounded local-parameter propagation that is sound (F1) and terminating (F2). + // + // F1 (LOCAL PROPAGATION PERMISSION): a proven SERVER/REQUEST/RESPONSE argument may escape into + // a local callee ONLY when the corresponding parameter position is itself SUPPORTED — a + // present, non-rest, plain-identifier parameter that actually receives the authority class in + // Phase A. A rest / object-destructuring / array-destructuring / missing parameter never + // carries the class, so the call-site MUST fail closed. Phase-A propagation and the Phase-B + // call-allowance derive from the SAME `supportedPropagationParamName` rule, so they cannot + // disagree — a call is NOT allowed merely because the callee resolves locally. + // + // F2 (CONVERGENCE): the bounded provenance fixpoint has an explicit terminal state. CONVERGED — + // an iteration adds no new fact — proceeds to Phase B. EXHAUSTED — the final permitted + // iteration still added a fact — DENIES the source (the map is known-partial; a privileged + // parameter may remain unclassified). Termination measure: a finite symbol × authority-class + // lattice with MONOTONIC fact addition, bounded by the 64-iteration cap. A reverse-ordered + // forwarding chain advances exactly one authority hop per pass, so a chain longer than the cap + // EXHAUSTS and fails closed instead of letting the deepest parameter escape unclassified. + // ===================================================================================== + describe('D3 propagation soundness + convergence (PR #64 F1/F2)', () => { + // ---- F1: a proven argument into an UNSUPPORTED parameter shape MUST DENY. ---- + const f1Deny: readonly { readonly form: string; readonly source: string }[] = [ + { form: '1. REQUEST into a rest parameter use(...values)', source: H + RK + `function use(...values: any[]): void {\n const s = values[0][rk];\n s.connect(80, 'example.com');\n}\nhttp.createServer((req: any) => {\n use(req);\n});` }, + { form: '2. RESPONSE into a rest parameter use(...values)', source: H + `function use(...values: any[]): void {\n values[0].futureMemberX();\n}\nhttp.createServer((req: any, res: any) => {\n use(res);\n});` }, + { form: '3. SERVER into a rest parameter use(...values)', source: metaServerHead + `function use(...values: any[]): void {\n values[0].futureMemberX = 1;\n}\nuse(server);` }, + { form: '4. REQUEST into an object-destructuring parameter use({ url })', source: H + `function use({ url }: any): void {\n void url;\n}\nhttp.createServer((req: any) => {\n use(req);\n});` }, + { form: '5. REQUEST into an array-destructuring parameter use([first])', source: H + `function use([first]: any): void {\n void first;\n}\nhttp.createServer((req: any) => {\n use(req);\n});` }, + { form: '6. RESPONSE into an object-destructuring parameter use({ end })', source: H + `function use({ end }: any): void {\n void end;\n}\nhttp.createServer((req: any, res: any) => {\n use(res);\n});` }, + { form: '7. SERVER into an object-destructuring parameter use({ listen })', source: metaServerHead + `function use({ listen }: any): void {\n void listen;\n}\nuse(server);` }, + { form: '8. REQUEST where the callee has NO corresponding parameter use()', source: H + `function use(): void {}\nhttp.createServer((req: any) => {\n use(req);\n});` }, + ]; + for (const { form, source } of f1Deny) { + it(`DENIES (F1 unsupported-parameter escape) ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(true); + }); + } + + // ---- F1: a proven argument into a SUPPORTED simple-identifier parameter, plus a benign call + // into an unsupported shape (no proven argument), stay ALLOWED (preserve real-host policy). ---- + const f1Allow: readonly { readonly form: string; readonly source: string }[] = [ + { form: '9. REQUEST into a simple identifier use(r) then r.method', source: H + `function use(r: any): void {\n void r.method;\n}\nhttp.createServer((req: any) => {\n use(req);\n});` }, + { form: '10. RESPONSE into a simple identifier use(r) then r.end()', source: H + `function use(r: any): void {\n r.end();\n}\nhttp.createServer((req: any, res: any) => {\n use(res);\n});` }, + { form: '11. SERVER into a simple identifier use(s) then s.listen()', source: metaServerHead + `function use(s: any): void {\n s.listen(4317);\n}\nuse(server);` }, + { form: '12. a benign non-proven argument into a rest parameter use(1)', source: H + `function use(...values: any[]): void {\n void values.length;\n}\nuse(1);\nhttp.createServer(() => {});` }, + ]; + for (const { form, source } of f1Allow) { + it(`ALLOWS (F1 supported-parameter / benign) ${form}`, () => { + expect(usesOutboundNetwork(source)).toBe(false); + }); + } + + // ---- F2 helper: a REVERSE-ordered forwarding chain. `f` is the sink; `f`..`f1` forward + // their parameter one hop. The sink is declared FIRST and the seed `f1(req)` LAST, so a + // top-down provenance pass advances exactly ONE hop — n passes are needed, exceeding the + // 64-iteration cap when n > 64. Under the OLD loop the deepest parameter stayed unclassified + // and its `p[rk]` socket read escaped; the convergence terminal now fails such a chain closed. + const reverseChain = (n: number, sinkBody: string): string => { + const lines: string[] = [`function f${String(n)}(p: any): void {\n${sinkBody}\n}`]; + for (let k = n - 1; k >= 1; k--) lines.push(`function f${String(k)}(p: any): void {\n f${String(k + 1)}(p);\n}`); + return H + RK + lines.join('\n') + `\nhttp.createServer((req: any) => {\n f1(req);\n});`; + }; + const misuseSink = ` const s = p[rk];\n s.connect(80, 'example.com');`; + const benignSink = ` void p.method;`; + + // ---- F2: chains longer than the cap EXHAUST → fail closed (65 = cap+1 is the minimal case). ---- + for (const n of [65, 80]) { + it(`DENIES (F2 exhaustion, reverse chain of ${String(n)} hops > 64-cap)`, () => { + expect(usesOutboundNetwork(reverseChain(n, misuseSink))).toBe(true); + }); + } + + // ---- F2: within-bound convergence, cycles, and real hosts behave under NORMAL policy. ---- + it('DENIES (F2 within-bound converged reverse chain of 40 hops with a misuse — normal policy)', () => { + expect(usesOutboundNetwork(reverseChain(40, misuseSink))).toBe(true); + }); + it('ALLOWS (F2 within-bound converged reverse chain of 40 hops, benign sink)', () => { + expect(usesOutboundNetwork(reverseChain(40, benignSink))).toBe(false); + }); + it('ALLOWS (F2 simple cycle a<->b, benign — terminates and converges)', () => { + expect( + usesOutboundNetwork(H + `function a(x: any): void {\n b(x);\n}\nfunction b(x: any): void {\n a(x);\n}\nhttp.createServer((req: any) => {\n a(req);\n});`), + ).toBe(false); + }); + it('DENIES (F2 mutually-recursive cycle with a misuse — terminates and fails closed)', () => { + expect( + usesOutboundNetwork(H + RK + `function a(x: any): void {\n const s = x[rk];\n s.connect(80, 'example.com');\n b(x);\n}\nfunction b(x: any): void {\n a(x);\n}\nhttp.createServer((req: any) => {\n a(req);\n});`), + ).toBe(true); + }); + it('ALLOWS (F2 no-propagation real host converges immediately)', () => { + expect( + usesOutboundNetwork(H + `http.createServer((req: http.IncomingMessage, res: http.ServerResponse): void => {\n res.statusCode = 200;\n res.end(req.method ?? '');\n});`), + ).toBe(false); + }); + }); }); // ---------------------------------------------------------------------------