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

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