Skip to content

no-exec-interpolated-command / no-child-process-interpolated-command: chained method call on interpolated command (e.g. .trim()) [Content truncated due to length] #52241

Description

@github-actions

Rules

eslint-factory/src/rules/no-exec-interpolated-command.ts and eslint-factory/src/rules/no-child-process-interpolated-command.ts — both ship an identical, independently-duplicated getDynamicCommandKind / isDynamicStringConcatenation / isStaticExpression trio.

Problem

getDynamicCommandKind only recognizes exactly two shapes as "dynamic":

function getDynamicCommandKind(node: TSESTree.Expression): string | null {
  if (node.type === "TemplateLiteral" && node.expressions.length > 0) return "interpolated template literal";
  if (isDynamicStringConcatenation(node)) return "dynamic string concatenation";
  return null;
}

Any other expression wrapping an interpolated template literal or dynamic concatenation returns null (not flagged) — including the extremely common pattern of chaining a string method after the template literal, e.g. `git checkout ${branch}`.trim() or `git log --author=${author}`.toLowerCase(). In that case the argument node is a CallExpression (the .trim() call), not a TemplateLiteral, so getDynamicCommandKind sees an unrecognized node type and bails out — even though the underlying string is exactly as attacker-controllable as the untrimmed version.

The companion resolver resolveWriteOnceInitializerChain (command-initializer-utils.ts) doesn't help here either: its loop condition is while (candidate.type === AST_NODE_TYPES.Identifier ...), so it only unwraps identifier indirection (const cmd = ...; exec.exec(cmd)), never unwraps a CallExpression like .trim() sitting directly in the argument position or inside an identifier's initializer.

Concrete escape (PoC, not currently live in the codebase)

// no-exec-interpolated-command
exec.exec(`git checkout ${branch}`.trim(), []); // NOT flagged

// no-child-process-interpolated-command
const { execSync } = require("child_process");
execSync(`git log --author=${author}`.toLowerCase()); // NOT flagged

Both are functionally identical injection risks to the already-covered exec.exec(`git checkout ${branch}`, []) and execSync(`git log --author=${x}`) invalid-test cases in each rule's test suite — the only difference is the trailing method call, which is a routine normalization step (trimming whitespace, case-folding a branch/author name) that a developer would very plausibly add without realizing it defeats the lint rule.

Acceptance criteria

  • In both rules, extend getDynamicCommandKind (or add a small recursive unwrap step before calling it, mirroring the recursive containsEnvAccess traversal already used in require-nan-check-after-env-numeric-parse.ts) to see through a CallExpression whose callee is a MemberExpression on a dynamic receiver (e.g. .trim(), .trimStart(), .trimEnd(), .toLowerCase(), .toUpperCase(), .replace(), .replaceAll()) — i.e. check the receiver (callee.object) recursively instead of only the outermost node.
  • Add an invalid test to each rule's test file for the .trim()-chained interpolated-template-literal case, and one for the .toLowerCase()-chained case.
  • Keep all existing valid cases passing unchanged, in particular the already-valid "fully static concatenation" and "static template literal" cases — a .trim() call on a fully static string must remain unflagged.
  • Consider factoring the now-triplicated isStaticExpression / isDynamicStringConcatenation / getDynamicCommandKind trio (identical in both rule files today) into the shared command-initializer-utils.ts so this fix (and future ones) only needs to land once.

Scope

eslint-factory/** (in scope). No live grounding found in actions/setup/js/** today (grep across non-test .cjs files found template-literal-then-.trim()/.toLowerCase() chains only in unrelated string-building code, never as a direct exec/child_process command argument) — the gap is a code-reasoning finding against the rule's own stated threat model, not an active exploit.

Generated by 🤖 ESLint Refiner · agent · 203.5 AIC · ⌖ 34.4 AIC · ⊞ 4.7K ·

  • expires on Aug 18, 2026, 9:54 PM UTC-08:00

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions