Skip to content

eslint-factory: resolveInitializer mishandles destructured bindings, silently misses dynamic commands/routes across 3 rules #53914

Description

@github-actions

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

  • const [cmd] = [ + "git checkout ${danger}" + ]; execSync(cmd); is now flagged (currently silent).
  • const { cmd } = { cmd: + "git checkout ${danger}" + }; execSync(cmd); is now flagged.
  • Existing valid/invalid test cases in no-child-process-interpolated-command.test.ts, no-exec-interpolated-command.test.ts, and no-github-request-interpolated-route.test.ts continue to pass unchanged.
  • A genuinely static destructured binding (e.g. const [cmd] = ["git status"]; execSync(cmd);) remains unflagged (no new false positive).
  • Add regression tests for destructured command/route variables to all three consuming rules' test files, plus a direct unit test on resolveWriteOnceInitializerChain/getDynamicCommandKind if a test file for command-initializer-utils.ts is 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).

Generated by 🤖 ESLint Refiner · agent · 279 AIC · ⌖ 5.05 AIC · ⊞ 5.3K ·

  • expires on Aug 25, 2026, 9:32 PM UTC-08:00

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions