Summary
resolveInitializer in eslint-factory/src/rules/command-initializer-utils.ts (lines 9-36) resolves a local variable to its initializer expression by reading declarator.init directly, without checking that declarator.id is a plain Identifier:
const declarator = def.node as TSESTree.VariableDeclarator;
return declarator.init ?? null;
When the binding comes from array/object destructuring (const [cmd] = arr; or const { cmd } = obj;), declarator.init is the whole right-hand-side expression (arr / obj), not the specific value bound to cmd. The function should treat destructured bindings as unresolved (like it already does for parameters and re-assigned variables), but instead silently substitutes the wrong sub-expression.
This function backs resolveWriteOnceInitializerChain and getDynamicCommandKind, which are shared by three rules: no-child-process-interpolated-command, no-exec-interpolated-command, and no-github-request-interpolated-route. A single fix here fixes all three.
Grounding
The exact syntactic shape — an array-destructured variable feeding directly into an exec-family call — already exists in the live corpus at actions/setup/js/create_labels.cjs:46-56:
const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean);
...
const result = await exec.getExecOutput(bin, compileArgs, { ignoreReturnCode: true });
This instance is currently benign (cmdPrefixStr comes from process.env.GH_AW_CMD_PREFIX, and the mis-resolution here happens to bottom out in null/not-flagged either way), but it proves the destructuring-into-exec-arg pattern is one this codebase actually authors, not a hypothetical.
Constructed proof of an actual miss (the bug in isolation):
function run(danger) {
const template = `git checkout ${danger}`; // dynamic, attacker-influenced
const [cmd] = [template]; // destructure the dynamic value out of an array
execSync(cmd); // NOT flagged today — should be
}
Trace: resolveInitializer(cmd) returns the ArrayExpression [template] (not template). Back in getDynamicCommandKind, that ArrayExpression isn't a TemplateLiteral, isn't a dynamic BinaryExpression, and isn't a string-transform call, so the function returns null — the interpolated command silently passes.
Note try-catch-rule-utils.ts's isChildProcessObjectBinding (used for detecting require("child_process") bindings) already guards this correctly with declarator.id.type === AST_NODE_TYPES.Identifier before reading declarator.init — confirming this is an inconsistency/oversight specific to resolveInitializer, not an intentional design choice elsewhere in the same file family.
Proposed fix
In resolveInitializer, reject (return null) when declarator.id.type !== AST_NODE_TYPES.Identifier (or !== identifier.name doesn't match), mirroring the guard already used in try-catch-rule-utils.ts's isChildProcessObjectBinding.
Acceptance criteria
Filed by the ESLint Refiner daily workflow. Findings grounded statically via grep on non-test actions/setup/js/**/*.cjs (npm/live-lint unavailable in this environment).
Generated by 🤖 ESLint Refiner · agent · 279 AIC · ⌖ 5.05 AIC · ⊞ 5.3K · ◷
Summary
resolveInitializerineslint-factory/src/rules/command-initializer-utils.ts(lines 9-36) resolves a local variable to its initializer expression by readingdeclarator.initdirectly, without checking thatdeclarator.idis a plainIdentifier:When the binding comes from array/object destructuring (
const [cmd] = arr;orconst { cmd } = obj;),declarator.initis the whole right-hand-side expression (arr/obj), not the specific value bound tocmd. The function should treat destructured bindings as unresolved (like it already does for parameters and re-assigned variables), but instead silently substitutes the wrong sub-expression.This function backs
resolveWriteOnceInitializerChainandgetDynamicCommandKind, which are shared by three rules:no-child-process-interpolated-command,no-exec-interpolated-command, andno-github-request-interpolated-route. A single fix here fixes all three.Grounding
The exact syntactic shape — an array-destructured variable feeding directly into an exec-family call — already exists in the live corpus at
actions/setup/js/create_labels.cjs:46-56:This instance is currently benign (
cmdPrefixStrcomes fromprocess.env.GH_AW_CMD_PREFIX, and the mis-resolution here happens to bottom out innull/not-flagged either way), but it proves the destructuring-into-exec-arg pattern is one this codebase actually authors, not a hypothetical.Constructed proof of an actual miss (the bug in isolation):
Trace:
resolveInitializer(cmd)returns theArrayExpression [template](nottemplate). Back ingetDynamicCommandKind, thatArrayExpressionisn't aTemplateLiteral, isn't a dynamicBinaryExpression, and isn't a string-transform call, so the function returnsnull— the interpolated command silently passes.Note
try-catch-rule-utils.ts'sisChildProcessObjectBinding(used for detectingrequire("child_process")bindings) already guards this correctly withdeclarator.id.type === AST_NODE_TYPES.Identifierbefore readingdeclarator.init— confirming this is an inconsistency/oversight specific toresolveInitializer, not an intentional design choice elsewhere in the same file family.Proposed fix
In
resolveInitializer, reject (returnnull) whendeclarator.id.type !== AST_NODE_TYPES.Identifier(or!== identifier.namedoesn't match), mirroring the guard already used intry-catch-rule-utils.ts'sisChildProcessObjectBinding.Acceptance criteria
const [cmd] = [+ "git checkout ${danger}" +]; execSync(cmd);is now flagged (currently silent).const { cmd } = { cmd:+ "git checkout ${danger}" +}; execSync(cmd);is now flagged.no-child-process-interpolated-command.test.ts,no-exec-interpolated-command.test.ts, andno-github-request-interpolated-route.test.tscontinue to pass unchanged.const [cmd] = ["git status"]; execSync(cmd);) remains unflagged (no new false positive).resolveWriteOnceInitializerChain/getDynamicCommandKindif a test file forcommand-initializer-utils.tsis added.Filed by the ESLint Refiner daily workflow. Findings grounded statically via grep on non-test
actions/setup/js/**/*.cjs(npm/live-lint unavailable in this environment).