Files
reverse-engineering-anything/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts
T
吴佳浩Albenandmorluto 3b2522bd3b fix(javascript): reject a tls protocol version Node refuses at the call site (#9557)
* fix(javascript): reject a tls protocol version Node refuses at the call site

`tls.connect` resolves `minVersion`/`maxVersion` before it opens a socket, so a bad literal
throws from the call itself and no socket exists. The acquisition filter decided the port
only, so it reported a complete built-in resource for a call that throws.

Measured on Node 24.22.1:

| `minVersion` / `maxVersion` | outcome |
| --- | --- |
| `"TLSv1"`, `"TLSv1.1"`, `"TLSv1.2"`, `"TLSv1.3"` | socket created |
| `null`, `undefined`, absent | socket created |
| `"TLSv1.0"`, `"tlsv1"`, `"tlsv1.2"`, `"TLSv1.4"`, `"nope"`, `""` | THREW `ERR_TLS_INVALID_PROTOCOL_VERSION` |
| `5`, `true`, `{}`, `[]` | THREW `ERR_TLS_INVALID_PROTOCOL_VERSION` |
| `1n` | THREW `TypeError` |

The accepted set is exact and case-sensitive, so it is a closed list rather than a pattern.
`net` ignores these members, so only `tls` is consulted. The member is read in write order,
which is why `{minVersion: "nope", minVersion: "TLSv1.2"}` keeps its acquisition; a spread,
a computed key or an accessor leaves the answer undecided rather than decided against.

22 cases, 11 failing before this commit. `src/domain/javascript`: 5040 to 5062 passing.
`tsc` 0; `build` 0; `npm run format:check` 0; `npm run lint` 0 errors; `knip` 0.

* fix(javascript): read every tls options slot and check both version members

Four review findings, each measured on Node 24.22.1. All four were real gaps in the first
version of this check.

**An accepted `minVersion` returned before `maxVersion` was read.** `tls.connect({port: 80,
minVersion: "TLSv1.2", maxVersion: "nope"})` throws, because both members are resolved
before the socket opens. The loop now continues past an accepted version and returns only
for a rejected one.

**Only argument zero was read.** `tls` also accepts `(port, options)`, `(port, host,
options)` and `(path, options)`, and all three throw on a bad version:
`tls.connect(443, {minVersion: "nope"})`, `tls.connect(443, "127.0.0.1", {minVersion:
"nope"})` and `tls.connect("/tmp/x.sock", {minVersion: "nope"})`. Every object literal in
the argument list is now read; a spread argument shifts those positions, so it leaves the
answer undecided instead of picking a wrong slot.

**A signed number was left unknown.** `minVersion: -1`, `+1` and `-0` all throw, but they
arrive as a `UnaryExpression` and matched none of the literal arms. `readExactJavaScriptLiteral`
already normalises `-1` and `+1`; `-0` is the one case it excludes, so a sign over a numeric
literal is decided explicitly here rather than treated as unknown.

13 cases added; reverting the slot and loop changes fails 4 of them.
`src/domain/javascript`: 5062 to 5075 passing. `tsc` 0; `build` 0;
`npm run format:check` 0; `npm run lint` 0 errors; `knip` 0.

* fix(javascript): merge the tls arguments and honour a supplied secure context

Four review findings, each measured on Node 24.22.1. All four were real: the previous
reader checked each object argument independently and ignored the context.

**Several object arguments merge in order, so the later one wins.** The reader rejected the
first object and stopped:

| call | Node 24.22.1 |
| --- | --- |
| `tls.connect({port: 443, minVersion: "nope"}, {minVersion: "TLSv1.2"})` | SOCKET |
| `tls.connect({port: 443, minVersion: "TLSv1.2"}, {minVersion: "nope"})` | THREW |
| `tls.connect({port: 443}, {minVersion: "nope"})` | THREW |

The members are now resolved across the whole argument list in write order, so a later
argument replaces an earlier one the way Node's merge does.

**A supplied `secureContext` bypasses version parsing entirely.** Node uses
`options.secureContext || tls.createSecureContext(options)`, so
`tls.connect({port: 80, secureContext: ctx, minVersion: "nope"})` connects while a `null`
context throws. An unreadable context leaves the answer undecided.

**An ordinary method member cannot install a version.** The reader skipped any object
containing a method, so `{checkServerIdentity() {}, minVersion: "nope"}` kept a socket
Node never creates. Only an accessor runs a body and leaves the member open; a plain method
is a value Node never reads as a version name. A `minVersion() {}` member is itself such a
value, so it rejects.

**Signed BigInt and function-shaped versions were left unknown.** `-1n`, `() => {}`,
`function () {}` and `class {}` are all truthy non-strings that Node refuses; none matched
an existing arm. They are now decided.

12 cases added; reverting the merge and context changes fails 2 of them and reverting the
method arm fails 6. `src/domain/javascript`: 5075 to 5087 passing. `tsc` 0; `build` 0;
`npm run format:check` 0; `npm run lint` 0 errors; `knip` 0.

* fix(javascript): read only the options slot tls.connect copies, and its accessors

Six review findings, each measured on Node 24.22.1. All six were real.

**A falsy `secureContext` does not bypass version parsing.** Node tests
`options.secureContext` for truth, so `{secureContext: false, minVersion: "nope"}`, and `0`
and `""` with it, all throw. The guard only recognised nullish syntax, so it dropped real
throws. `staticTruthiness` now decides the context: a known-falsy value keeps checking, a
known-truthy one ends the check, and an unreadable one stays undecided.

**Node copies at most one options object.** Measured:

| call | result |
| --- | --- |
| `(443, {minVersion: "TLSv1.2"}, {minVersion: "nope"})` | SOCKET — the third argument is ignored |
| `(443, {minVersion: "nope"}, {minVersion: "TLSv1.2"})` | THREW |
| `(443, "127.0.0.1", {minVersion: "nope"})` | THREW — the host fills slot 1 |
| `({port: 443, minVersion: "nope"}, {minVersion: "TLSv1.2"})` | SOCKET — the second object wins |

The reader treated every object argument as merged in order, which let an ignored object
decide. `tlsOptionsObjects` now models the two overloads: an object in slot 0 is merged with
slot 1, and a port or path in slot 0 makes the first object among slots 1 and 2 the only one
read.

**An exact computed key names the member.** `{["minVersion"]: "nope"}` throws, but the
reader marked every computed key unknown before the shared name resolver saw it. Only an
unresolvable key is unknown now, and the case that asserted the old behaviour uses a
binding key so it still covers the unknown path.

**An accessor can write the member first.** `{get side() { this.minVersion = "TLSv1.2"; },
minVersion: "nope"}` connects, because Node's own copy runs the getter before it reads
`minVersion`; and a `set minVersion` in an earlier argument absorbs the later write. Both
are bodies this reader does not run, so an accessor anywhere in the merged objects now
leaves every version member undecided. A plain method still rejects, since Node never reads
it as a version name.

**An unshadowed `NaN` or `Infinity` is a non-string version.** Both throw, and neither
matched an existing arm. They are decided the same way the adjacent port filter decides
them, through `isUnmodifiedGlobalReference`, so a shadowed binding stays unknown.

16 cases added; reverting the truthiness, accessor and computed-key changes fails 2 of them.
`src/domain/javascript`: 5075 to 5188 passing. `tsc` 0; `build` 0;
`npm run format:check` 0; `npm run lint` 0 errors.

* fix(javascript): use the nullable key owner and settle unary versions

Six review findings, each measured on Node 24.22.1. All six were real, and three of them
were removing real sockets.

**`semanticStaticPropertyName` encodes an unresolved key as `""`, never `undefined`.** The
guard tested `undefined`, so it never fired and an unresolved computed key was read as a
member literally named empty. `const k = "minVersion";
tls.connect({port: 443, minVersion: "nope", [k]: "TLSv1.2"})` connects on Node 24.22.1
because the later computed member wins, while the analysis filtered on the stale `"nope"`
and dropped the socket. `semanticStaticPropertyKey` is the nullable owner and is now used,
with `null` treated as unknown.

**A getter can replace a plain version method before Node copies it.** The method branch
ignored `accessorWritten`, so `{get side() { this.minVersion = "TLSv1.2"; }, minVersion()
{}}` still returned `"rejects"` although it connects. The same unknown result now applies to
methods when an accessor is present.

**A non-literal second argument is a merged options object, not an absent one.**
`const o = {minVersion: "TLSv1.2"}; tls.connect({port: 443, minVersion: "nope"}, o)`
connects. The slot reader mapped every non-object to `undefined`, so only the stale first
object was examined. A non-object in a copied slot now stays unknown.

**`-0` is falsy, so a signed-zero context does not bypass parsing.** The local truthiness
helper did not fold a unary `-`, so `{secureContext: -0, minVersion: "nope"}` was treated as
a possible bypass although it throws. The shared `staticJavaScriptTruthiness` owner already
handles signed zero and replaces the local helper, which is deleted.

**A unary operator over a settled operand is a settled non-string version.** `!0`, `!1`,
`~~0`, `!!0` and `typeof 1` all throw `ERR_TLS_INVALID_PROTOCOL_VERSION`; the previous arm
recognised only `+` and `-`, and the exact-literal and shape arms did not reach them.
`void <expr>` stays accepted, because it is `undefined`.

The slot reader also had to distinguish a host string in slot 1 from an options object, since
`tls.connect(443, "127.0.0.1", {minVersion: "nope"})` throws while
`tls.connect(443, {minVersion: "nope"})` is the two-slot form.

`src/domain/javascript`: 5188 to 5197 passing. Reverting the key owner fails 1 case and
reverting the unary arms fails 2. `tsc` 0; `build` 0; `npm run format:check` 0;
`npm run lint` 0 errors.

* test(javascript): distinguish package scope in route fixtures

* test(apple): separate identity and refusal fixture groups

* fix(javascript): share TLS option facts and preserve merge uncertainty

* fix(javascript): separate TLS assignment authority from copy facts

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-11-09 22:09:29 +08:00

263 lines
8.8 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains conditional built-in effects through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-conditional-builtins-");
const source = `
const fs = require('fs');
const proc = require('process');
const http = require('http');
const { spawn } = require('child_process');
const { setTimeout: schedule } = require('timers');
fs.readFileSync('config.json');
proc.env.MODE;
const stream = fs.createReadStream('data');
stream.close();
http.get('http://example.test');
spawn('tool', []);
schedule(() => {}, 1);
`;
await writeFile(join(root, "candidate.MJS"), source);
await writeFile(join(root, "proven.cjs"), source);
await writeFile(
join(root, "mixed.MJS"),
`import * as builtin from 'node:fs'; const alternate = require('fs'); const selected = globalThis.flag ? builtin : alternate; selected.readFileSync('mixed.json');`,
);
await writeFile(
join(root, "mixed-nested.MJS"),
`import * as builtin from 'node:fs'; const alternate = require('fs'); const selected = globalThis.flag ? builtin : alternate; selected.promises.readFile('mixed.json');`,
);
await writeFile(
join(root, "mutation.mjs"),
`import http from 'http'; require('http').get = custom; http.get('http://example.test');`,
);
const result = await analyzeJavaScriptDirectory(surface, cli, root);
const graph = result.semantic_graph;
for (const [path, candidate] of [
["candidate.MJS", true],
["proven.cjs", false],
] as const) {
const nodes = graph.nodes.filter(
(node) =>
node.identity.module_path === path &&
[
"child-process",
"config-source",
"request",
"resource",
"timer",
].includes(node.kind),
);
expect(nodes.map(({ kind }) => kind).sort()).toEqual([
"child-process",
"config-source",
"config-source",
"request",
"resource",
"timer",
]);
for (const node of nodes) {
expect(node.properties.builtin_authority).toBe(
candidate ? "candidate" : undefined,
);
const evidence = graph.evidence_contexts.find(
({ context_id }) => context_id === node.evidence.context_id,
);
if (candidate)
expect(evidence).toMatchObject({
state: "inferred",
confidence: "low",
});
}
const ids = new Set(nodes.map(({ node_id }) => node_id));
const relations = graph.relations.filter(
({ source_node_id, target_node_id }) =>
ids.has(source_node_id) || ids.has(target_node_id),
);
for (const relation of [
"reads-config",
"reads-environment",
"acquires",
"releases",
"constructs-request",
"spawns",
"schedules-timer",
])
expect(relations).toContainEqual(
expect.objectContaining({
relation,
resolution: candidate ? "candidate" : "resolved",
}),
);
if (candidate) {
expect(
relations.every(({ resolution }) => resolution !== "resolved"),
).toBe(true);
for (const relation of relations) {
const evidence = graph.evidence_contexts.find(
({ context_id }) => context_id === relation.evidence.context_id,
);
expect(evidence).toMatchObject({
state: "inferred",
confidence: "low",
});
}
}
}
const conditionalMutation = graph.nodes.find(
(node) =>
node.identity.module_path === "mutation.mjs" && node.kind === "request",
);
expect(conditionalMutation?.properties.builtin_authority).toBe("candidate");
const evidence = graph.evidence_contexts.find(
({ context_id }) =>
context_id === conditionalMutation?.evidence.context_id,
);
expect(evidence).toMatchObject({ state: "inferred", confidence: "low" });
for (const path of ["mixed.MJS", "mixed-nested.MJS"]) {
const mixed = graph.nodes.find(
(node) =>
node.identity.module_path === path && node.kind === "config-source",
);
expect(mixed?.properties.builtin_authority).toBe("candidate");
expect(
graph.evidence_contexts.find(
({ context_id }) => context_id === mixed?.evidence.context_id,
),
).toMatchObject({ state: "inferred", confidence: "low" });
expect(
graph.relations.find(
({ relation, source_node_id, target_node_id }) =>
relation === "reads-config" &&
(source_node_id === mixed?.node_id ||
target_node_id === mixed?.node_id),
),
).toMatchObject({ resolution: "candidate" });
}
},
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves child-process rejection and unresolved admission through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-builtin-admission-");
const cases = [
["rejected", 'const child=cp.spawn("tool",[],{cwd:5});', false],
[
"qualified",
'const child=cp.spawn("tool",[],{cwd:new globalThis.URL("file:///tmp/")});',
true,
],
[
"unknown",
'const child=cp.spawn("tool",[],{cwd:new DirectoryURL()});',
true,
],
] as const;
for (const [name, body] of cases) {
await writeFile(
join(root, name + ".mjs"),
'import cp from "node:child_process";' +
"function entry(){" +
body +
"}",
);
}
const { semantic_graph: graph } = await analyzeJavaScriptDirectory(
surface,
cli,
root,
);
for (const [name, , retained] of cases) {
const nodes = graph.nodes.filter(
(node) =>
node.identity.module_path === name + ".mjs" &&
node.kind === "child-process",
);
expect(nodes, name).toHaveLength(retained ? 1 : 0);
const ids = new Set(nodes.map((node) => node.node_id));
const links = graph.relations.filter(
(relation) =>
relation.relation === "spawns" &&
(ids.has(relation.source_node_id) ||
ids.has(relation.target_node_id)),
);
expect(links, name).toHaveLength(retained ? 1 : 0);
}
},
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves TLS rejection and unresolved admission through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-builtin-admission-");
const cases = [
[
"rejected",
'const socket=tls.connect({port:443,minVersion:"nope",secureContext:undefined},()=>{});socket.destroy();',
false,
],
[
"accepted",
'const socket=tls.connect({port:443,minVersion:"nope"},()=>{},{minVersion:"TLSv1.2"});socket.destroy();',
true,
],
[
"constructor-target",
'class O {port=443;set minVersion(value){}};const socket=tls.connect(new O(),{secureContext:false,minVersion:"nope"});socket.destroy();',
true,
],
[
"class-source",
'const socket=tls.connect({port:443,minVersion:"nope"},null,class {static minVersion="TLSv1.2"});socket.destroy();',
true,
],
[
"copied-prototype",
'const socket=tls.connect({port:443,secureContext:false},{["__proto__"]:{set minVersion(value){}},minVersion:"nope"});socket.destroy();',
true,
],
[
"unknown",
'const socket=tls.connect({port:443,minVersion:"nope",...options});socket.destroy();',
true,
],
] as const;
for (const [name, body] of cases) {
await writeFile(
join(root, name + ".mjs"),
'import tls from "node:tls";' + "function entry(){" + body + "}",
);
}
const { semantic_graph: graph } = await analyzeJavaScriptDirectory(
surface,
cli,
root,
);
for (const [name, , retained] of cases) {
const nodes = graph.nodes.filter(
(node) =>
node.identity.module_path === name + ".mjs" &&
node.kind === "resource",
);
expect(nodes, name).toHaveLength(retained ? 1 : 0);
const ids = new Set(nodes.map((node) => node.node_id));
const links = graph.relations.filter(
(relation) =>
relation.relation === "releases" &&
(ids.has(relation.source_node_id) ||
ids.has(relation.target_node_id)),
);
expect(links, name).toHaveLength(retained ? 1 : 0);
}
},
);