Skip to content

eslint-factory: add no-child-process-interpolated-command to catch shell-injection command strings#47555

Merged
pelikhan merged 7 commits into
mainfrom
copilot/eslint-refiner-flag-interpolated-command-strings
Jul 23, 2026
Merged

eslint-factory: add no-child-process-interpolated-command to catch shell-injection command strings#47555
pelikhan merged 7 commits into
mainfrom
copilot/eslint-refiner-flag-interpolated-command-strings

Conversation

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

eslint-factory previously enforced interpolated-command safety only for github-script’s injected exec API (@actions/exec arg-splitting). This change adds dedicated coverage for child_process shell-evaluated command strings, where interpolation is a shell-injection risk.

  • What this adds

    • New rule: no-child-process-interpolated-command
    • Flags interpolated template literals and dynamic + concatenation when passed as the command string to:
      • exec, execSync
      • spawn, spawnSync when shell: true
      • execFile, execFileSync when shell: true
  • Binding resolution covered

    • require("child_process") and require("node:child_process")
    • ESM imports
    • Destructured bindings ({ execSync })
    • Namespace/member calls (cp.execSync(...))
    • Aliased member bindings (const run = cp.execSync; run(...))
  • Scope boundaries

    • Does not flag fully static command strings.
    • Does not flag spawn/spawnSync without shell options.
    • Does not flag execFile/execFileSync without shell: true.
    • Does not overlap with github-script exec.exec(...) / exec.getExecOutput(...); that remains under no-exec-interpolated-command.
  • Plugin/docs wiring

    • Rule registered in eslint-factory/src/index.ts
    • README rule table + dedicated section added, including out-of-scope distinction from no-exec-interpolated-command
const { spawn } = require("child_process");

// now flagged:
spawn(`git checkout ${branch}`, { shell: true });

// preferred pattern:
execFileSync("git", ["checkout", branch]); // no shell string interpolation

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add ESLint rule to flag interpolated command strings in child_process eslint-factory: add no-child-process-interpolated-command to catch shell-injection command strings Jul 23, 2026
Copilot AI requested a review from pelikhan July 23, 2026 11:41
@pelikhan
pelikhan marked this pull request as ready for review July 23, 2026 11:43
Copilot AI review requested due to automatic review settings July 23, 2026 11:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a security-focused ESLint rule for detecting shell-injection-prone child_process command interpolation.

Changes:

  • Implements binding and shell-option detection.
  • Adds rule tests and plugin registration.
  • Documents supported and excluded patterns.
Show a summary per file
File Description
no-child-process-interpolated-command.ts Implements the rule.
no-child-process-interpolated-command.test.ts Tests command patterns and bindings.
src/index.ts Registers the plugin rule.
README.md Documents usage and scope.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Medium

"prefer-core-logging": preferCoreLoggingRule,
"no-core-error-then-process-exit": noCoreErrorThenProcessExitRule,
"no-core-error-then-process-exitcode": noCoreErrorThenProcessExitCodeRule,
"no-child-process-interpolated-command": noChildProcessInterpolatedCommandRule,

function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod): boolean {
if (!requiresShellTrue(method)) return true;
return isShellTrueOption(node.arguments[1]) || isShellTrueOption(node.arguments[2]);
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #47555 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions github-actions Bot mentioned this pull request Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 81/100 — Excellent

Analyzed 1 test(s): 1 design, 0 implementation, 0 violation(s).

📊 Metrics (14 scenarios covered)
Metric Value
Analyzed 1 test block (JS/TypeScript via vitest)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 7 valid + 7 invalid scenarios (100%)
Duplicate clusters 0
Inflation ratio 0.4:1 (59 test : 147 prod lines)
🚨 Violations 0

Detailed Analysis

Test: no-child-process-interpolated-command (single test block)

  • Classification: Design test (behavioral contract)
  • Coverage:
    • Valid cases (7): Static strings, template literals without expressions, spawn with arguments array, shell:false, safe import variants
    • Invalid cases (7): Interpolated templates, string concatenation, shell:true combinations across all vulnerable methods (exec, execSync, spawn, spawnSync, execFile, execFileSync)
    • Methods tested: All 6 child_process methods
    • Patterns detected: Template interpolation, string concatenation, shell option handling, require/import variants

Strengths:

  • Comprehensive coverage of attack surface (interpolation + concatenation patterns)
  • Explicit error assertions verifying correct messageId and data fields
  • Edge cases include shell:true/false conditionals on methods that require it
  • Minimal test inflation (0.4:1 ratio shows production code is ~2.5x the test)
  • Clean RuleTester pattern with no mock libraries

Verdict

Passed. 0% implementation tests (threshold: 30%). No guideline violations. All error paths explicitly asserted.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 11.1 AIC · ⌖ 7.75 AIC · ⊞ 7.1K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Quality Sentinel: 81/100. 0% implementation tests (threshold: 30%). All error paths covered across 6 child_process methods and attack patterns (interpolation, concatenation, shell:true). Clean test design with no violations.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd — requesting changes on correctness gaps and a missing activation step.

📋 Key Themes & Highlights

Issues

  1. Rule never activates — registered in the plugin but absent from eslint.config.cjs; the linter will never fire on the target codebase.
  2. shell: 'sh' bypassisShellTrueOption checks only === true; string shell values (valid per Node.js docs) are silently ignored.
  3. Variable options false-negativeconst opts = { shell: true }; spawn(cmd, opts) is not flagged because only inline object literals are inspected.
  4. Test structure — a single monolithic it block makes failures hard to diagnose; edge cases for the above gaps are absent.

Positive Highlights

  • ✅ Thorough binding-resolution logic covering require, ESM imports, destructuring, and member aliases.
  • ✅ Clean scope boundary: spawn/execFile only flagged when shell: true is explicitly set.
  • ✅ Clear README section distinguishing this rule from no-exec-interpolated-command.
  • ✅ Good coverage of the happy path — 7 valid + 7 invalid cases all pass.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 41.7 AIC · ⌖ 4.94 AIC · ⊞ 6.7K
Comment /matt to run again


function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod): boolean {
if (!requiresShellTrue(method)) return true;
return isShellTrueOption(node.arguments[1]) || isShellTrueOption(node.arguments[2]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isShellTrueOption only handles inline object literals — const opts = { shell: true }; spawn(cmd, opts) is not flagged even though the call is shell-evaluated. This creates a false-negative that lets injection slip through.

💡 Suggested approach

Resolve the identifier to its initialiser using scope analysis, similar to how isChildProcessObjectBinding resolves cp bindings. If the variable's init is an ObjectExpression, inspect it the same way as an inline literal.

@copilot please address this.

});

describe("no-child-process-interpolated-command", () => {
it("flags dynamic child_process command strings for shell-evaluated methods", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test suite is a single it block with 14 cases, all testing one axis at a time. There are no tests for the variable-options false-negative (const opts = { shell: true }), the shell: 'sh' string value (truthy but not === true), or execFile when arguments come in position 1 vs position 2. Splitting into focused it blocks per scenario would make failures much easier to pinpoint.

💡 Suggested structure
describe('shell option detection', () => {
  it('flags spawn with inline { shell: true }', ...)
  it('does not flag spawn with variable opts containing { shell: true } (known limitation)', ...)
  it('does not flag spawn with { shell: "sh" } — only boolean true is recognised', ...)
})
describe('execFile argument positions', () => {
  it('checks position 1 (no args array)', ...)
  it('checks position 2 (with args array)', ...)
})

Document known limitations as skipped/todo tests so they act as regression anchors.

@copilot please address this.


for (const prop of optionsArg.properties) {
if (prop.type !== AST_NODE_TYPES.Property || prop.computed) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isShellTrueOption accepts only shell: true (boolean). A call with shell: 'sh' or shell: '/bin/bash' is also shell-evaluated but will not be flagged, creating a silent bypass.

💡 Suggested fix

Change the value check to treat any truthy shell value as shell-enabled:

const val = prop.value;
if (val.type === AST_NODE_TYPES.Literal) {
  return !!val.value; // true, 'sh', '/bin/bash' all truthy
}

Add a test case: spawn(\cmd ${x}`, { shell: 'sh' })` should be invalid.

@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — Good security intent, but multiple correctness gaps must be addressed before this protects anything in production.

Blocking issues (4)

Critical: Rule is never activated

Adding it to index.ts registers it in the plugin object, but eslint.config.cjs is what actually runs against source. The rule is missing from the config, so it currently has zero effect on actions/setup/js.

High: Variable-reference options bypass

const opts = { shell: true }; spawn(\git ${branch}`, [], opts)is not flagged.isShellTrueOptiononly sees inlineObjectExpression` nodes. For a security rule, a trivially-avoidable false negative is a significant gap. At minimum, document the limitation; ideally flag any non-literal options argument as potentially shell-enabled.

High: shell: '/bin/bash' (truthy string) not detected

Node.js accepts a shell path string as the shell option. prop.value.value === true misses it. Change to !!prop.value.value to cover all truthy literals.

Medium: String-literal ESM import specifier missed

import { 'exec' as run } from 'child_process' (ES2022) produces a Literal imported node, which falls through to null and is never matched.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 50.2 AIC · ⌖ 5.04 AIC · ⊞ 5.7K
Comment /review to run again

const keyName = prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name : prop.key.type === AST_NODE_TYPES.Literal ? prop.key.value : null;
if (keyName !== "shell") continue;

return prop.value.type === AST_NODE_TYPES.Literal && prop.value.value === true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shell: true check misses truthy string values: { shell: '/bin/bash' } also evaluates via shell but is not flagged, leaving a gap in injection detection.

💡 Details and fix

Node.js accepts any truthy value for shell — including a string path to the shell binary. The current check prop.value.value === true only matches the boolean true:

// Not flagged today:
spawn(`git checkout ${branch}`, { shell: '/bin/bash' });  // shell injection risk

Broaden the check to cover any truthy shell value:

return prop.value.type === AST_NODE_TYPES.Literal && !!prop.value.value;

This catches shell: true, shell: '/bin/sh', shell: '/bin/bash', etc.

"no-child-process-interpolated-command": noChildProcessInterpolatedCommandRule,
"no-exec-interpolated-command": noExecInterpolatedCommandRule,
"require-execsync-try-catch": requireExecSyncTryCatchRule,
"require-execfilesync-try-catch": requireExecFileSyncTryCatchRule,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule is not activated in eslint.config.cjs: registering the rule in index.ts doesn't enable it — the config file must also include an entry, otherwise the rule never runs on actions/setup/js.

💡 Fix

Add to eslint-factory/eslint.config.cjs inside the rules block:

"gh-aw-custom/no-child-process-interpolated-command": "warn",

Without this, the rule is dead code in production even though it tests fine in isolation.


function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod): boolean {
if (!requiresShellTrue(method)) return true;
return isShellTrueOption(node.arguments[1]) || isShellTrueOption(node.arguments[2]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable-reference options object silently bypasses detection: const opts = { shell: true }; spawn(\git ${branch}`, [], opts)is not flagged becauseisShellTrueOptiononly handles inlineObjectExpression` nodes.

💡 Details

isShellTrueOption immediately returns false for any non-ObjectExpression argument, including Identifier references to option objects. This is a false negative for the rule's core security purpose — a developer can trivially avoid the lint check by hoisting the options:

const opts = { shell: true };
spawn(`rm -rf ${userInput}`, [], opts);  // NOT flagged

Full resolution (resolving the variable through scope) is non-trivial, but the limitation should at minimum be documented in the rule description and/or a code comment so future maintainers know the boundary.

Alternatively, for the shell: true check, emit a warning whenever the options argument is an Identifier or other non-literal expression (conservative over-flagging is preferable to under-flagging for injection rules).

for (const def of variable.defs) {
if (isChildProcessImportBinding(def) && def.node.type === AST_NODE_TYPES.ImportSpecifier) {
const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier ? def.node.imported.name : null;
if (importedName === method) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String-literal ESM import specifier missed: import { 'exec' as run } from 'child_process' (ES2022 module string names) is silently skipped — run(\git ${cmd}`)` would not be flagged.

💡 Details and fix

At line ~60, the import binding check does:

const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier
  ? def.node.imported.name
  : null;  // <-- string literal specifier falls through as null

String-name imports (import { 'exec' as run }) use an AST_NODE_TYPES.Literal node for imported. Fix:

const importedName =
  def.node.imported.type === AST_NODE_TYPES.Identifier
    ? def.node.imported.name
    : def.node.imported.type === AST_NODE_TYPES.Literal
      ? String(def.node.imported.value)
      : null;

This is an edge case, but it's a silent false negative in a security rule.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: no-child-process-interpolated-command

Good addition — shell-injection via interpolated command strings is a real risk, and the rule logic, binding resolution, and docs are well-structured. A few blocking issues need to be addressed before merging.

Blocking

  1. Rule never activates (pre-existing comment, line 59 of index.ts): The rule is registered in the plugin map but never added to eslint-factory/eslint.config.cjs. Without an entry there, the rule won't run over actions/setup/js.

  2. Variable-reference shell options not detected (pre-existing comment, line 50 of rule): isShellTrueOption only handles inline { shell: true } object literals. const opts = { shell: true }; spawn(cmd, opts) silently passes.

  3. Spread options silently skipped (new comment, line 46 of rule): A SpreadElement in the options position is treated as non-shell and bypassed. spawn(cmd ${x}, ...opts) is missed entirely.

Non-blocking

  1. No invalid ESM test (new comment, line 29 of test): The ESM import branch is only exercised by a single valid case; an invalid ESM case should be added to confirm coverage.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 42.9 AIC · ⌖ 4.7 AIC · ⊞ 5K

],
invalid: [
{
code: `const { execSync } = require("child_process"); execSync(\`git checkout \${branch}\`);`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing invalid ESM test cases.

The test suite only validates a single valid ESM case but contains no invalid ESM examples. The ESM import path through isChildProcessImportBinding is a separate code branch from the require path, so it deserves at least one invalid case (e.g. import { execSync } from "child_process"; execSync(\git checkout ${branch}`)`) to confirm that branch is actually exercised.

@copilot please address this.


function requiresShellTrue(method: ChildProcessMethod): boolean {
return SHELL_CONDITIONAL_METHODS.has(method);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spread element in options position silently skips the shell check.

isShellTrueOption returns false early for SpreadElement, so spawn(cmd, ...opts) or spawn(cmd, args, ...opts) is treated as non-shell and skipped. If a spread contains { shell: true }, the rule will silently miss the violation. The safer default would be to treat an unknown spread as possibly shell-enabled (return true from the spread branch) so the rule errs on the side of flagging rather than silently passing.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

🔍 PR Triage — §30008120537

Field Value
Category feature
Risk 🟡 medium
Score 60 / 100
Breakdown Impact 30 · Urgency 18 · Quality 12
Action 🚀 fast_track
CI ✅ passing (30 checks, all success)
Review CHANGES_REQUESTED (automated) after initial approve

Notes: New eslint rule no-child-process-interpolated-command for shell-injection detection. 4 files (new rule + tests + docs). CI green. CHANGES_REQUESTED may indicate config issue — review changes requested content.

Generated by 🔧 PR Triage Agent · sonnet46 54 AIC · ⌖ 5.54 AIC · ⊞ 5.7K ·

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address unresolved review comments, and rerun checks once the branch is up to date.

Unresolved review threads (newest first):

Please also update the branch body/metadata if needed so the stale automated changes-requested state can clear once the threads are resolved.

Generated by 👨‍🍳 PR Sous Chef · gpt54 35.1 AIC · ⌖ 7.07 AIC · ⊞ 7K ·
Comment /souschef to run again

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address unresolved review comments, and rerun checks once the branch is up to date.

Unresolved review threads (newest first):...

Addressed the unresolved rule-feedback items in commit c83b351. I enabled gh-aw-custom/no-child-process-interpolated-command in /home/runner/work/gh-aw/gh-aw/eslint-factory/eslint.config.cjs, added scoped identifier resolution for options objects, broadened shell detection to truthy literals (including shell path strings), handled spread options conservatively, and added the missing ESM/identifier-based test coverage in /home/runner/work/gh-aw/gh-aw/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts.

I ran local validation (make agent-report-progress) successfully. CI checks on this new HEAD are stale and won’t auto-rerun from agent pushes; a maintainer re-trigger is still required to refresh checks and clear stale changes-requested metadata.

Copilot AI and others added 3 commits July 23, 2026 13:34
…g note

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
@pelikhan
pelikhan merged commit 2e1ce14 into main Jul 23, 2026
@pelikhan
pelikhan deleted the copilot/eslint-refiner-flag-interpolated-command-strings branch July 23, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants