diff --git a/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts new file mode 100644 index 0000000000..a2b1d004ec --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * `net` and `tls` validate a numeric port before creating a socket, so a literal + * port outside `[0, 65535]`, a fractional or non-finite number, or a BigInt, + * boolean or `null` first argument throws synchronously and no socket exists. + * + * Measured on Node 24.22.1: + * + * net.connect(0) / 80 / 65535 -> Socket created + * net.connect(65536) / -1 / 1.5 -> THREW ERR_SOCKET_BAD_PORT + * net.connect(NaN) / Infinity -> THREW ERR_SOCKET_BAD_PORT + * net.connect(0n) / true / null -> THREW ERR_INVALID_ARG_TYPE + * net.connect("") -> THREW ERR_SOCKET_BAD_PORT + * net.connect("/tmp/x.sock") -> Socket created (Unix socket path) + * net.connect({ port: 80 }) -> Socket created + * net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here) + */ +const socketAcquisitionsFor = (call: string) => + analyzeJavaScriptSemantics( + `import net from "node:net";\nimport tls from "node:tls";\nfunction g(p) { ${call}; }`, + ).resourceOperations.map(({ kind, method }) => ({ kind, method })); + +it.each([ + ["a port above the range", `net.connect(65536)`], + ["a negative port", `net.connect(-1)`], + ["a fractional port", `net.connect(1.5)`], + ["a NaN port", `net.connect(NaN)`], + ["an Infinity port", `net.connect(Infinity)`], + ["an overflowing numeric port", `net.connect(1e400)`], + ["a BigInt port", `net.connect(0n)`], + ["a boolean port", `net.connect(true)`], + ["a null port", `net.connect(null)`], + ["an empty path", `net.connect("")`], + ["a tls port above the range", `tls.connect(65536)`], + ["a tls negative port", `tls.connect(-1)`], + ["a tls NaN port", `tls.connect(NaN)`], + ["a createConnection port above the range", `net.createConnection(65536)`], + ["a createConnection empty path", `net.createConnection("")`], +] as const)("emits no socket acquisition for %s", (_name, call) => { + expect(socketAcquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["a zero port", `net.connect(0)`, "connect"], + ["a low port", `net.connect(80)`, "connect"], + ["the highest port", `net.connect(65535)`, "connect"], + ["a Unix socket path", `net.connect("/tmp/x.sock")`, "connect"], + ["an options object", `net.connect({ port: 80 })`, "connect"], + ["an omitted argument", `net.connect()`, "connect"], + ["an explicit undefined port", `net.connect(undefined)`, "connect"], + ["a tls port", `tls.connect(80)`, "connect"], + ["a createConnection port", `net.createConnection(80)`, "createConnection"], +] as const)("keeps the socket acquisition for %s", (_name, call, method) => { + expect(socketAcquisitionsFor(call)).toEqual([{ kind: "acquire", method }]); +}); diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index ec5e5252f4..d441c70d1c 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -64,7 +64,11 @@ export const collectJavaScriptSemanticResources = ( callableStack: [], ancestors: [], }; - const acquisitions = collectAcquisitions(program, context); + const acquisitions = collectAcquisitions(program, context).filter( + (candidate) => + !socketPortInFirstArgument(candidate) || + !rejectsSocketPortLiteral(candidate.node, context), + ); return [ ...acquisitions.map(immutableAcquisition), ...collectReleases(program, context, acquisitions), @@ -220,3 +224,56 @@ const acquisitionMethod = ( }; const resourceCandidateId = (candidate: ResourceCandidate): string => `resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; + +/** + * `net` and `tls` validate a numeric port before creating a socket, so a + * literal port outside `[0, 65535]`, a fractional, non-finite, BigInt, boolean + * or `null` first argument throws synchronously and no socket exists. A string + * is a Unix socket path and an object may carry a path, so only an exactly + * readable non-port literal is decided. `""` is a path with no length, which + * Node also rejects. + * + * `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a + * missing option rather than a bad port, so they stay undecided here. + */ +const rejectsSocketPortLiteral = ( + node: t.CallExpression | t.OptionalCallExpression, + context: DataEffectTraversalContext, +): boolean => { + const port = node.arguments[0]; + if (port === undefined || t.isSpreadElement(port)) return false; + const value = unwrapJavaScriptExpression(port).node; + if (t.isStringLiteral(value)) return value.value.length === 0; + if ( + t.isNullLiteral(value) || + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) + ) + return true; + if (t.isNumericLiteral(value)) + return ( + !Number.isInteger(value.value) || value.value < 0 || value.value > 65535 + ); + // `NaN` and `Infinity` are unshadowed globals, and neither is a port. + if ( + t.isIdentifier(value) && + (value.name === "NaN" || value.name === "Infinity") && + isUnmodifiedGlobalReference(value, context.state, value.name) + ) + return true; + // `-1` and `-1.5` are unary expressions over numeric literals: always out of + // range because a port is never negative. + if (t.isUnaryExpression(value)) { + const operand = unwrapJavaScriptExpression(value.argument).node; + return ( + t.isNumericLiteral(operand) && + (value.operator === "-" || !Number.isInteger(operand.value)) + ); + } + return false; +}; + +/** Only `net`/`tls` sockets take a port in argument 0. */ +const socketPortInFirstArgument = ( + candidate: Pick, +): boolean => candidate.specifier === "net" || candidate.specifier === "tls";