diff --git a/bun.lock b/bun.lock index 149492d..fff4aae 100644 --- a/bun.lock +++ b/bun.lock @@ -8,9 +8,14 @@ "ws": "^8.18.3", "zod": "^4.1.5", }, + "devDependencies": { + "acorn": "8.15.0", + }, }, }, "packages": { + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], diff --git a/package.json b/package.json index 77622e6..8e7b9fc 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,14 @@ ".": "./src/index.js" }, "scripts": { - "test": "bun test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", - "test:node": "node --test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", + "test": "bun test test/no-raw-tls-client.test.mjs test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", + "test:node": "node --test test/no-raw-tls-client.test.mjs test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", "test:cloak-profile": "node --test test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs", "test:cloak-profile:bun": "bun test test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs" }, + "devDependencies": { + "acorn": "8.15.0" + }, "dependencies": { "ws": "^8.18.3", "zod": "^4.1.5" diff --git a/test/no-raw-tls-client.test.mjs b/test/no-raw-tls-client.test.mjs new file mode 100644 index 0000000..0562cac --- /dev/null +++ b/test/no-raw-tls-client.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { evaluateShippedSource, findRawTlsClients, isScannedSourceFile } from "./tls-client-detector.mjs"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +// Security contract: CVE-2026-48618 must not gain an unreviewed raw-host path. +// The bundled ws@8.21.3 connector parses new URL(address) before host/SNI; +// OmOWright supplies headers only. See senpi#3078 and oh-my-openagent#9892. +// Entries must pin { file, call: exact normalized text, count, reason }. +// Empty today: dependency bundles are reviewed separately, not file-exempted. +// Known limits: createRequire through namespace/default module imports or +// require("node:module") chains; require.apply, require aliases/sequence calls; +// detached getBuiltinModule aliases/.call; variable Bun keys; globalThis Bun +// destructuring; variable/const-folded module specifiers; Reflect.get(Bun, "connect"). +// This is a regression guard, not a complete data-flow or evasion detector. +const ALLOWLIST = []; + +describe("raw TLS client guard (CVE-2026-48618)", () => { + // Security regression: in-tree runtime access must expose the client, not just its global lookup. + for (const { name, sample } of [ + { + "name": "global alias", + "sample": "const target = globalThis; target.Bun?.connect(o);" + }, + { + "name": "Reflect direct", + "sample": "Reflect.get(globalThis, \"Bun\")?.connect(o);" + }, + { + "name": "Reflect alias", + "sample": "const target = globalThis; Reflect.get(target, \"Bun\").connect(o);" + }, + { + "name": "process destructure", + "sample": "const { getBuiltinModule: g } = process; g(\"node:tls\");" + } +]) { + it("detects the AST-round " + name + " client", () => { + assert.ok(findRawTlsClients(sample).some(hit => name === "process destructure" ? hit.text === 'g("node:tls")' : hit.text.endsWith("connect(o)")), sample); + }); + } + it("keeps non-client Bun lookups and unrelated Reflect receivers clean", () => { + for (const sample of ['const target = globalThis; const bun = Reflect.get(target, "Bun"); bun?.hash(value);', 'const local = {}; Reflect.get(local, "Bun").connect(o);']) + assert.deepEqual(findRawTlsClients(sample), []); + }); + it("preserves object trailing-comma reflows without changing literals or counts", () => { + const entry = { file: "src/client.js", call: 'tls.connect({ host: u.hostname, options: { servername: u.hostname, label: "comma, }" } })', count: 2, reason: "URL-parsed host" }; + const content = entry.call + ';\n tls.connect({ host: u.hostname, options: { servername: u.hostname, label: "comma, }", }, },);'; + const scan = [{ path: entry.file, content }]; + assert.deepEqual(evaluateShippedSource(scan, [entry]), { offenders: [], stale: [] }); + assert.ok(evaluateShippedSource([{ path: entry.file, content: content + entry.call }], [entry]).stale.length); + assert.ok(evaluateShippedSource([{ path: entry.file, content: content.replace('comma, }', 'comma,}') }], [entry]).offenders.length); + }); + + it("detects Bun destructuring assignment", () => { + assert.ok(findRawTlsClients("let connect; ({ connect } = Bun); connect({ tls: true });").length); + }); + + it("detects calls independently of their module acquisition pins", () => { + for (const [sample, call] of [ + [ + "import { default as client } from \"node:https\"; client.request(options);", + "client.request(options)" + ], + [ + "import * as client from \"node:tls\"; client.connect(options);", + "client.connect(options)" + ], + [ + "const client = require(\"node:https\"); client.get(url);", + "client.get(url)" + ], + [ + "const { connect: dial } = await import(`node:tls`); dial(options);", + "dial(options)" + ], + [ + "import client from \"https\"; const request = client.request; request(options);", + "request(options)" + ], + [ + "const { default: client } = await import(\"node:https\"); client.get(url);", + "client.get(url)" + ] +]) { + assert.ok(findRawTlsClients(sample).some(hit => hit.text === call), sample); + } + }); + it("does not interpret regex or literal text as calls", () => { + for (const sample of ['const s = "tls.connect(o)";', 'const s = `tls.connect(o)`;', 'const r = /tls.connect\\(o\\)/;']) + assert.deepEqual(findRawTlsClients(sample), []); + }); + + it("catches reviewer H1 regex/templates and H2 acquisitions (security regression)", () => { + for (const sample of [ + "const r = /\"/; tls.connect(o); const s = \"x\";", + "const r = /`/; tls.connect(o); const s = `x`;", +`const s = \`\${a ? \`"\` : ""}\`; tls.connect(o); const z = "q";`, + "const r = /a\\/*/; tls.connect(o); /* */", + "import { default as h } from \"node:https\"; h.request(o);", + "export { default } from \"node:https\";", + "export { default as h } from \"node:http2\";", + "Bun?.connect({ tls: true });", + "const { connect } = Bun; connect({ tls: true });", + "const c = Bun.connect; c({ tls: true });", + "Bun.connect.call(null, { tls: true });", + "Bun.connect.apply(null, [{ tls: true }]);" +]) assert.ok(findRawTlsClients(sample).length, sample); + }); + it("fails closed and names malformed shipped source", () => { + for (const content of ['const s = "unterminated', 'const s = `unterminated', 'function f() {']) { + assert.throws(() => evaluateShippedSource([{ path: "src/broken.js", content }], []), /src\/broken\.js/); + } + }); + it("detects sampled direct clients and module acquisitions", () => { + const samples = [ + 'import { createServer } from "node:https";', + 'export { createSecureServer } from "node:http2";', + "tls.createSecureContext(options)", + "tls.connect({ host, port: 443 })", + "https.request(url, callback)", + "https.get(url)", + "http2.connect(authority)", + "Bun.connect({ hostname, tls: true })", + "new https.Agent(options)", + "new tls.TLSSocket(socket, options)", + "const options = { checkServerIdentity: check }", + 'tls["connect"](options)', + "Bun[`connect`](options)", + ]; + for (const module of ["tls", "https", "http2"]) { + for (const prefix of ["", "node:"]) { + const name = prefix + module; + const member = module === "https" ? "request" : "connect"; + samples.push( + `import client from "${name}";`, + `import * as client from '${name}';`, + `import { ${member} as client } from "${name}";`, + `import client, { ${member} } from "${name}";`, + `import "${name}";`, + `const client = require("${name}");`, + `const client = await import('${name}');`, + `const client = await import(\`${name}\`);`, + `const client = require(\`${name}\`);`, + `const client = process.getBuiltinModule("${name}");`, + `const client = process.getBuiltinModule(\`${name}\`);`, + `export { ${member} as client } from "${name}";`, + `export * from "${name}";`, + `export * as client from "${name}";`, + ); + } + } + samples.push('import { connect } from "node:\\u0074ls";'); + for (const sample of samples) { + assert.ok(findRawTlsClients(sample).length > 0, `missed: ${sample}`); + } + assert.deepEqual(findRawTlsClients('\n\ntls.connect({ host: "x)" });'), [ + { line: 3, text: 'tls.connect({ host: "x)"})' }, + ]); + assert.deepEqual(findRawTlsClients("Bun.connect(options)"), [ + { line: 1, text: "Bun.connect(options)" }, + ]); + }); + + it("ignores comments, literal prose and calls to inbound neighbors", () => { + for (const sample of [ + '// tls.connect(options)\n/* import client from "tls" */', + 'const text = "tls.connect(options)";', + 'const text = `import client from "tls";`;', + 'import { connect } from "node:tlsx";', + 'import { connect } from "./tls";', + "fetch(url); new URL(url); tls.createServer(options); Bun.serve(options);", + ]) assert.deepEqual(findRawTlsClients(sample), [], sample); + }); + + it("scans tracked shipped source, not tests or fixtures", () => { + for (const file of ["src/client.js", "bin/client.mjs", "bridge/extension/background.js", "new/client.ts"]) { + assert.equal(isScannedSourceFile(file), true, file); + } + for (const file of ["test/client.js", "src/client.test.js", "src/fixtures/client.js", "src/types.d.ts", "README.md"]) { + assert.equal(isScannedSourceFile(file), false, file); + } + const files = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" }) + .split("\0").filter(isScannedSourceFile); + assert.ok(files.includes("src/core.js") && files.includes("bridge/extension/background.js")); + const verdict = evaluateShippedSource(files.map(file => ({ + path: file, content: readFileSync(new URL(file, new URL("../", import.meta.url)), "utf8"), + })), ALLOWLIST); + assert.deepEqual(verdict, { offenders: [], stale: [] }, + "Raw TLS client added: prove URL-derived host/SNI, then pin exact call text and count with provenance."); + }); + + it("pins exact call text, path, count and reviewed host provenance", () => { + const entry = { file: "src/client.js", call: "http2.connect(baseUrl)", count: 2, reason: "URL-parsed authority" }; + const scan = content => [{ path: entry.file, content }]; + assert.deepEqual(evaluateShippedSource(scan("http2.connect(baseUrl); http2.connect(\nbaseUrl,\n);"), [entry]), + { offenders: [], stale: [] }); + for (const content of [ + "http2.connect(baseUrl); http2.connect(baseUrl); tls.connect(options);", + "http2.connect(otherUrl); http2.connect(baseUrl);", + ]) assert.ok(evaluateShippedSource(scan(content), [entry]).offenders.length); + for (const content of [ + "http2.connect(baseUrl);", "", "http2.connect(baseUrl);".repeat(3), + ]) assert.ok(evaluateShippedSource(scan(content), [entry]).stale.length); + assert.ok(evaluateShippedSource([], [entry]).stale.length); + assert.ok(evaluateShippedSource([{ path: "src/other.js", content: "http2.connect(baseUrl);" }], [entry]).offenders.length); + assert.ok(evaluateShippedSource(scan("http2.connect(baseUrl);".repeat(2)), [{ ...entry, reason: "" }]).stale.length); + assert.ok(evaluateShippedSource(scan(""), [{ ...entry, count: 0 }]).stale.length); + }); +}); diff --git a/test/tls-client-detector.mjs b/test/tls-client-detector.mjs new file mode 100644 index 0000000..ed2e6fc --- /dev/null +++ b/test/tls-client-detector.mjs @@ -0,0 +1,195 @@ +import { parse } from "acorn"; + +const CLIENTS = { + tls: new Set(["connect", "createConnection", "TLSSocket", "createSecureContext", "checkServerIdentity"]), + https: new Set(["request", "get", "Agent", "globalAgent"]), + http2: new Set(["connect"]), + Bun: new Set(["connect"]), +}; + +function nodes(tree) { + const result = []; + const visit = node => { + if (!node || typeof node.type !== "string") return; + result.push(node); + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === "object") visit(value); + } + }; + visit(tree); + return result; +} + +function literal(node) { + if (node?.type === "Literal" && typeof node.value === "string") return node.value; + if (node?.type === "TemplateLiteral") { + let value = node.quasis[0].value.cooked; + for (let i = 0; i < node.expressions.length; i++) { + const part = literal(node.expressions[i]); + if (part === undefined) return undefined; + value += part + node.quasis[i + 1].value.cooked; + } + return value; + } + if (node?.type === "BinaryExpression" && node.operator === "+") { + const left = literal(node.left), right = literal(node.right); + if (left !== undefined && right !== undefined) return left + right; + } +} + +function unwrap(node) { + return node?.type === "ChainExpression" ? unwrap(node.expression) : node; +} + +function member(node) { + node = unwrap(node); + if (node?.type !== "MemberExpression") return undefined; + return node.computed ? literal(node.property) : node.property.name; +} + +function normalizeNode(node, content) { + const spans = nodes(node).filter(n => n.type === "Literal" || n.type === "TemplateElement") + .sort((a, b) => a.start - b.start); + const plain = text => text.replace(/\s+/g, " ").replace(/\(\s+/g, "(") + .replace(/,\s*([)}])/g, "$1").replace(/\s+([)}])/g, "$1"); + let end = node.start, text = ""; + for (const span of spans) { + if (span.start < end) continue; + text += plain(content.slice(end, span.start)) + content.slice(span.start, span.end); + end = span.end; + } + return (text + plain(content.slice(end, node.end))).trim().replace(/;$/, ""); +} + +export function findRawTlsClients(content, file = "source.js") { + let tree; + try { + tree = parse(content, { ecmaVersion: "latest", sourceType: "module", locations: true, + allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true }); + } catch (error) { + throw new SyntaxError(file + ": " + error.message, { cause: error }); + } + const list = nodes(tree); + const bindings = new Map([...Object.keys(CLIENTS), "globalThis"].map(name => [name, name])); + const requireNames = new Set(["require", "__require"]); + const requireFactories = new Set(); + const builtinNames = new Set(); + const kind = expression => { + const node = unwrap(expression); + if (node?.type === "Identifier") return bindings.get(node.name); + if (node?.type === "AwaitExpression") return kind(node.argument); + if (node?.type === "ImportExpression") return literal(node.source)?.replace(/^node:/, ""); + if (node?.type === "CallExpression") { + const callee = unwrap(node.callee); + const name = member(callee) ?? callee.name; + if (name === "get" && unwrap(callee.object)?.name === "Reflect" + && kind(node.arguments[0]) === "globalThis" && literal(node.arguments[1]) === "Bun") return "Bun"; + const factory = callee.type === "CallExpression" && requireFactories.has(callee.callee.name); + const call = member(callee) === "call" && requireNames.has(callee.object?.name); + if (requireNames.has(name) || name === "getBuiltinModule" || builtinNames.has(name) || factory || call) { + return literal(node.arguments[call ? 1 : 0])?.replace(/^node:/, ""); + } + } + if (node?.type === "MemberExpression") { + const base = kind(node.object), property = member(node); + if (base === "globalThis") return bindings.get(property); + if (CLIENTS[base]?.has(property)) return base + "#" + property; + if (base?.includes("#") && (property === "call" || property === "apply")) return base; + } + }; + for (const node of list) { + if (node.type !== "ImportDeclaration") continue; + const specifier = literal(node.source), module = specifier?.replace(/^node:/, ""); + for (const spec of node.specifiers) { + const imported = spec.imported?.name ?? spec.imported?.value; + if (module === "module" && imported === "createRequire") requireFactories.add(spec.local.name); + if (CLIENTS[module]) bindings.set(spec.local.name, + imported && imported !== "default" ? module + "#" + imported : module); + } + } + let changed; + do { + changed = false; + const bind = (name, value) => { + if (value && !bindings.has(name)) { bindings.set(name, value); changed = true; } + }; + for (const node of list) { + if (node.type !== "VariableDeclarator" || !node.init) continue; + if (node.init.type === "CallExpression" && requireFactories.has(node.init.callee.name)) { + requireNames.add(node.id.name); + } + const value = kind(node.init); + if (node.id.type === "Identifier") bind(node.id.name, value); + if (node.id.type === "ObjectPattern") for (const prop of node.id.properties) { + const name = prop.key?.name ?? literal(prop.key); + if (node.init.type === "Identifier" && node.init.name === "process" && name === "getBuiltinModule") builtinNames.add(prop.value.name); + if (name === "default" && CLIENTS[value]) bind(prop.value.name, value); + else if (CLIENTS[value]?.has(name)) bind(prop.value.name, value + "#" + name); + } + } + } while (changed); + const hits = [], seen = new Set(); + const calledMembers = new Set(list.filter(n => ["CallExpression", "NewExpression"].includes(n.type)) + .map(n => unwrap(n.callee))); + const add = node => { + if (seen.has(node.start)) return; + seen.add(node.start); + hits.push({ line: node.loc.start.line, text: normalizeNode(node, content) }); + }; + for (const node of list) { + if (node.type === "AssignmentExpression" && node.left.type === "ObjectPattern" && kind(node.right) === "Bun" + && node.left.properties.some(p => (p.key?.name ?? literal(p.key)) === "connect")) add(node); + if (["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) + && CLIENTS[literal(node.source)?.replace(/^node:/, "")]) add(node); + if (node.type === "ImportExpression" && CLIENTS[kind(node)]) add(node); + if (node.type === "CallExpression" && CLIENTS[kind(node)] && kind(node) !== "Bun") add(node); + if (["CallExpression", "NewExpression"].includes(node.type)) { + const value = kind(node.callee); + const [base, property] = value?.split("#") ?? []; + if (CLIENTS[base]?.has(property)) add(node); + } + if (node.type === "MemberExpression" && kind(node) === "Bun#connect") { + if (!calledMembers.has(node)) add(node); + } + if (node.type === "VariableDeclarator" && node.id.type === "ObjectPattern" && kind(node.init) === "Bun" + && node.id.properties.some(p => (p.key?.name ?? literal(p.key)) === "connect")) add(node); + if (node.type === "Property" && (node.key.name ?? literal(node.key)) === "checkServerIdentity") add(node); + if (node.type === "AssignmentExpression" && member(node.left) === "checkServerIdentity") add(node); + } + return hits; +} + +export function isScannedSourceFile(file) { + return /\.(?:[cm]?js|jsx|tsx?)$/.test(file) && + !/\.(?:test|spec)\.[^/]+$/.test(file) && !file.endsWith(".d.ts") && + !file.split("/").some(segment => [ + "test", "tests", "__tests__", "fixtures", "__fixtures__", "test-support", "test-fixtures", + "node_modules", ".git", + ].includes(segment)); +} + +export function evaluateShippedSource(scan, allowlist) { + const offenders = []; + const counts = new Map(allowlist.map(entry => [entry, 0])); + const calls = new Map(allowlist.map(entry => [entry, + normalizeNode(parse(entry.call, { ecmaVersion: "latest", sourceType: "module" }), entry.call)])); + for (const item of scan) { + for (const hit of findRawTlsClients(item.content, item.path)) { + const entry = allowlist.find(candidate => candidate.file === item.path && calls.get(candidate) === hit.text); + if (entry) counts.set(entry, counts.get(entry) + 1); + else offenders.push(`${item.path}:${hit.line} ${hit.text}`); + } + } + const stale = []; + for (const entry of allowlist) { + if (!scan.some(item => item.path === entry.file)) stale.push(`${entry.file}: no longer scanned`); + if (counts.get(entry) !== entry.count) { + stale.push(`${entry.file}: expected ${entry.call} x${entry.count}, found x${counts.get(entry)}`); + } + if (!entry.reason?.trim() || !Number.isInteger(entry.count) || entry.count < 1) { + stale.push(`${entry.file}: requires positive count and reviewed host provenance`); + } + } + return { offenders, stale }; +}