Skip to content

[eslint-miner] feat(eslint): add require-escaped-regexp-interpolation rule - #49106

Merged
pelikhan merged 3 commits into
mainfrom
eslint-miner/require-escaped-regexp-interpolation-e7dd5b6bd69b8048
Jul 30, 2026
Merged

[eslint-miner] feat(eslint): add require-escaped-regexp-interpolation rule#49106
pelikhan merged 3 commits into
mainfrom
eslint-miner/require-escaped-regexp-interpolation-e7dd5b6bd69b8048

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Summary

Adds a new custom ESLint rule, require-escaped-regexp-interpolation, to eslint-factory that flags values interpolated into new RegExp() template-literal patterns which are not passed through a regex-escaping helper first.

Motivation / evidence

Scanning actions/setup/js/**/*.cjs found a recurring, unenforced pattern: building a RegExp from a template literal that interpolates a raw variable without escaping regex metacharacters first. This risks unintended matches (if the value contains ., *, +, (, etc.) and, with attacker-influenced input, ReDoS.

Real occurrences in the corpus (not hypothetical):

  • actions/setup/js/sanitize_content_core.cjs:494new RegExp(\^[${fc}]{${fenceLen},}\s*$`)interpolatesfcandfenceLen` unescaped.
  • actions/setup/js/model_aliases.cjs:98new RegExp(\(^|[-\s])${qualifier}($|[-\s])`)interpolatesqualifier` unescaped.
  • actions/setup/js/interpolate_prompt.cjs:47new RegExp(\\$\{${varName}\}`, "g")interpolatesvarName` unescaped.

Running npm run lint:setup-js with the new rule enabled surfaces exactly 2 warnings, both at sanitize_content_core.cjs:494 — confirming the rule is specific and low-noise (not overly aggressive).

Rule design (low false-positive risk)

  • Only fires on new RegExp(...) where the callee is literally the global RegExp identifier.
  • Only fires when the first argument is a TemplateLiteral with at least one interpolated expression (plain string literals, non-interpolated templates, and identifier-only arguments like new RegExp(somePattern) are never flagged — no dataflow analysis needed, keeping false positives near zero).
  • Skips interpolations already routed through a recognizable escape helper, matched via /escape/i on the called function name (e.g. escapeRegExp(x), utils.escapeRegex(x)) or on the referenced identifier/property name (e.g. ESCAPED_NAME, escapedValue).
  • Reports each unescaped interpolated expression individually so partially-escaped patterns are still caught.

Changes

  • eslint-factory/src/rules/require-escaped-regexp-interpolation.ts — new rule implementation.
  • eslint-factory/src/rules/require-escaped-regexp-interpolation.test.ts — 8 test cases (valid: no interpolation, escaped via helper call, escaped via naming convention, unrelated new constructors; invalid: unescaped loop var, unescaped function param, multiple unescaped, mixed escaped/unescaped).
  • eslint-factory/src/index.ts — registers the new rule in the plugin.
  • eslint-factory/eslint.config.cjs — enables the rule as "warn" for actions/setup/js/**/*.cjs.

Validation

  • cd eslint-factory && npm install
  • cd eslint-factory && npm run build ✅ (tsc clean)
  • npx vitest run src/rules/require-escaped-regexp-interpolation.test.ts — 8/8 passed
  • npm test (full suite) — 401/406 passed; the 5 failures are in require-fs-io-try-catch.test.ts and are pre-existing on main (verified via git stash before this change), unrelated to this PR.
  • npm run lint:setup-js — runs clean structurally; new rule contributes exactly 2 real warnings, no new errors.

Scope

No changes outside actions/setup/js (read-only, scanned only) and eslint-factory (rule implementation/config). No Go code touched.

Generated by ESLint Miner · auto · 79.7 AIC · ⌖ 9.67 AIC · ⊞ 6.2K ·

  • expires on Aug 6, 2026, 1:39 AM UTC-08:00

Adds a new custom ESLint rule that flags values interpolated into
`new RegExp()` template-literal patterns that are not passed through
a regex-escaping helper first. Unescaped interpolation of a value
containing regex metacharacters can produce unintended matches or,
with attacker-controlled input, a ReDoS-prone pattern.

Evidence from actions/setup/js:
- sanitize_content_core.cjs:494 interpolates `fc` and `fenceLen`
  directly into a RegExp pattern without escaping.
- model_aliases.cjs:98 and interpolate_prompt.cjs:47 show the same
  shape (currently non-adversarial input, but the pattern is
  unenforced and easy to misuse with untrusted input in the future).

Running `npm run lint:setup-js` surfaces exactly 2 new warnings
(both at sanitize_content_core.cjs:494), confirming the rule is
specific and low-noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! eslint labels Jul 30, 2026
@pelikhan
pelikhan marked this pull request as ready for review July 30, 2026 09:41
Copilot AI review requested due to automatic review settings July 30, 2026 09:41
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

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

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

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

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

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ PR Code Quality Reviewer failed during code quality review.

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 custom ESLint rule intended to detect unescaped interpolation in dynamically constructed regular expressions.

Changes:

  • Implements and tests the new rule.
  • Registers it with the ESLint plugin.
  • Enables it as a warning for setup JavaScript linting.
Show a summary per file
File Description
eslint-factory/src/rules/require-escaped-regexp-interpolation.ts Implements interpolation detection.
eslint-factory/src/rules/require-escaped-regexp-interpolation.test.ts Adds rule tests.
eslint-factory/src/index.ts Registers the rule.
eslint-factory/eslint.config.cjs Enables the rule as a warning.

Review details

Tip

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

Comments suppressed due to low confidence (2)

eslint-factory/src/rules/require-escaped-regexp-interpolation.ts:76

  • The blanket loop also reports interpolations that intentionally inject validated regex source rather than literal text. In the enabled corpus, glob_pattern_helpers.cjs:74 interpolates a pattern produced by escapeRegexChars, model_aliases.cjs:35-40 interpolates a constant regex fragment, and the cited fenceLen is a numeric quantifier bound; escaping these expressions is either unnecessary or changes the pattern. The rule therefore produces false positives well beyond unsafe literal interpolation and needs a way to distinguish/exempt intentional regex fragments and numeric syntax before being enabled.
        for (const expr of patternArg.expressions) {
          if (isRecognizedAsEscaped(expr)) continue;

eslint-factory/src/rules/require-escaped-regexp-interpolation.ts:69

  • Checking only the identifier text does not ensure this is the global RegExp. A local parameter, import, variable, or class named RegExp will be linted as though it were the built-in constructor, producing false positives despite the rule's stated global-only scope. Check the ESLint scope for a local binding before reporting, as the existing require-new-url-try-catch rule does.
        if (node.callee.type !== AST_NODE_TYPES.Identifier || node.callee.name !== "RegExp") return;
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Medium

// Identifier/member names that indicate a value has already been passed through
// a regex-escaping helper before interpolation, e.g. `escapeRegExp(x)`,
// `escapeRegex(x)`, or a variable named `escapedFoo` / `ESCAPED_FOO`.
const ESCAPE_NAME_PATTERN = /escape/i;
Comment on lines +59 to +60
"Interpolated value `{{expr}}` in `new RegExp()` template literal is not passed through a regex-escaping helper. " +
"Escape regex metacharacters before interpolating, e.g. `{{expr}}.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")`.",
@github-actions

Copy link
Copy Markdown
Contributor Author

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Great work! 🚀 This PR looks ready for review.

Summary: The new require-escaped-regexp-interpolation ESLint rule is well-designed with:

  • Clear scope (only new RegExp(...) with interpolated templates)
  • Low false-positive risk (skips helpers and naming conventions)
  • Comprehensive test coverage (8/8 tests passing)
  • Real validation on the codebase (2 findings in sanitize_content_core.cjs, zero false positives)

Quality signals:

  • ✅ Tests present and passing (96 lines, 8/8 passed)
  • ✅ Clear, detailed description with motivation, design rationale, and validation results
  • ✅ Focused scope: only eslint-factory changes, no unrelated modifications
  • ✅ Generated by authorized agentic workflow (ESLint Miner)

This is ready for maintainer review!

Generated by ✅ Contribution Check · auto · 48.3 AIC · ⊞ 8.7K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Review: require-escaped-regexp-interpolation ESLint rule

Overall well-designed rule with low false-positive risk. One correctness bug found.

Blocking issue: ESCAPE_NAME_PATTERN = /escape/i uses a substring match that inadvertently matches unescape and unescapedValue identifiers, causing false negatives (unescaped values silently pass). Fix with a negative lookbehind: /(?<![a-z])escape/i.

A test case covering new RegExp(\${unescapedValue}`)` as invalid would confirm the fix.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 22.7 AIC · ⊞ 5.3K

// Identifier/member names that indicate a value has already been passed through
// a regex-escaping helper before interpolation, e.g. `escapeRegExp(x)`,
// `escapeRegex(x)`, or a variable named `escapedFoo` / `ESCAPED_FOO`.
const ESCAPE_NAME_PATTERN = /escape/i;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Bug: ESCAPE_NAME_PATTERN matches unescape and unescaped* identifiers (false negatives)

/escape/i is a substring match, so it inadvertently matches names like unescape, unescapedValue, or any identifier containing escape as a substring that is not actually an escaping helper:

const ESCAPE_NAME_PATTERN = /escape/i;
ESCAPE_NAME_PATTERN.test('unescapedValue'); // true — false negative!
ESCAPE_NAME_PATTERN.test('unescape');       // true — false negative!

This means new RegExp(\${unescapedValue}`)` would be silently skipped instead of reported.

Suggested fix — use a negative lookbehind to exclude the un prefix:

const ESCAPE_NAME_PATTERN = /(?<![a-z])escape/i;

This matches escapeRegExp, escapeRegex, ESCAPED_FOO, utils.escapeRegex but not unescape or unescapedValue.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor Author

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

🧪 Test Quality Sentinel Report

Test Quality Score: 96/100 — Excellent

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

📊 Metrics (8 tests)
Metric Value
Analyzed 8 (Go: 0, JS: 8)
✅ Design 8 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 7 (87.5%)
Duplicate clusters 0
Inflation ✅ No (1.1:1 ratio)
🚨 Violations 0

Test Breakdown

The require-escaped-regexp-interpolation rule tests comprehensively cover the security contract:

  • Metadata validation — docs URL correctly registered
  • Valid patterns — accepts non-interpolated, escaped-helper-wrapped, and unrelated constructor calls
  • Error detection — flags unescaped interpolations with precise reporting (loop vars, parameters, multiple interpolations, mixed scenarios)
  • Coverage: 87.5% of tests exercise error/edge cases; excellent real-world scenario selection (function parameters, ReDoS prevention)

Test inflation ratio of 1.1:1 is lean and appropriate for an ESLint rule.

Verdict

passed. 0% implementation tests (threshold: 30%). All tests verify behavioral design contracts; no violations.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 13.9 AIC · ⊞ 8.4K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 96/100. 0% implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Skills-Based Review 🧠

Applied /tdd and /grill-with-docs — requesting changes on two correctness issues.

📋 Key Themes & Highlights

Key Themes

  • False-negative risk in escape detection: ESCAPE_NAME_PATTERN = /escape/i matches unescapedValue, UNESCAPED_FOO, and the built-in unescape() — silently skipping those interpolations as if they were safe.
  • Missing test coverage for the false-negative case: no test verifies that unescapedValue is actually flagged.
  • Missing README entry: every other rule in eslint-factory has a table row and section in README.md; this rule has none.

Positive Highlights

  • ✅ Well-motivated rule backed by real corpus scanning with precise, low-noise validation (exactly 2 warnings).
  • ✅ Comprehensive test suite: 8 cases covering all valid/invalid branches.
  • ✅ Clean implementation — helper functions are small, well-named, and well-documented.
  • ✅ Security type (problem) and detailed error message with an inline fix example.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 41.6 AIC · ⊞ 7K
Comment /matt to run again

// a regex-escaping helper before interpolation, e.g. `escapeRegExp(x)`,
// `escapeRegex(x)`, or a variable named `escapedFoo` / `ESCAPED_FOO`.
const ESCAPE_NAME_PATTERN = /escape/i;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] ESCAPE_NAME_PATTERN = /escape/i will also match names like unescapedValue, UNESCAPED_FOO, and the built-in unescape() — all of which are not escape helpers and would silently suppress a warning (false negative).

💡 Suggested fix

Narrow the pattern to only match names that start with or contain a positive "escape" signal, e.g.:

// Only match names that begin with "escape" (case-insensitive) or contain "EscapeReg"
const ESCAPE_NAME_PATTERN = /^escape|escapeReg/i;

Alternatively, enumerate accepted helper names (like escapeRegExp, escapeRegex) for zero false negatives.

Add a test case:

// Should NOT be treated as safe — contains "escape" but is a bad signal:
{ code: "new RegExp(`${unescapedValue}`);" }

@copilot please address this.

invalid: [],
});
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The escapedValue naming-convention test case (new RegExp(\^${escapedValue}$`)) passes as valid, but there is no corresponding test that unescapedValueis correctly *flagged* as invalid. Without this, a regression in the/escape/i` pattern (e.g. after narrowing it) would go undetected.

💡 Add a companion invalid test
it("invalid: name starting with unescaped should NOT be treated as safe", () => {
  cjsRuleTester.run("require-escaped-regexp-interpolation", requireEscapedRegexpInterpolationRule, {
    valid: [],
    invalid: [
      {
        code: "new RegExp(`^${unescapedValue}$`);",
        errors: [{ messageId: "unescapedInterpolation" }],
      },
    ],
  });
});

@copilot please address this.

export const requireEscapedRegexpInterpolationRule = createRule({
name: "require-escaped-regexp-interpolation",
meta: {
type: "problem",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/grill-with-docs] The README.md for eslint-factory documents every other rule with a table entry and dedicated section (e.g. require-fetch-try-catch, no-duplicate-constant-values), but this new rule is not added. Users of the plugin have no way to discover the rule purpose, options, or how to suppress it.

💡 Add a README entry

Add a row to the rules table and a ### require-escaped-regexp-interpolation section following the pattern of existing rules, including: what it detects, motivation, and the escape-helper naming convention for suppression.

@copilot please address this.

NewExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier || node.callee.name !== "RegExp") return;

const patternArg = node.arguments[0];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The rule only fires on new RegExp(...) where the callee is a bare Identifier named RegExp. A common pattern like new globalThis.RegExp(...) or const RE = RegExp; new RE(...) would be silently skipped. This is a documented design choice (low false-positive), but the scope limitation should be called out in the rule description or a comment — not silently.

💡 Consider a doc comment at the NewExpression handler
// NOTE: Only bare `new RegExp(...)` is matched; aliased or member-accessed
// constructors (globalThis.RegExp, re2.RegExp, etc.) are intentionally out of
// scope to keep false-positive rate low.
NewExpression(node) {

This makes the deliberate scope explicit for future maintainers.

@copilot please address this.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

Copilot AI and others added 2 commits July 30, 2026 10:14
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…n require-escaped-regexp-interpolation

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@copilot run pr-finisher skill

Addressed both review issues in commit fix(eslint): tighten escape allowlist and recognize .replace() form:

  1. Overly broad /escape/i pattern — split into two targeted patterns:

    • ESCAPE_CALL_NAME_PATTERN = /escape.*reg|reg.*escape/i for function-call detection: requires both "escape" and "reg" to appear, so escapeHtml(userInput) and unescape(x) are no longer treated as regex-safe.
    • ESCAPED_IDENT_PATTERN = /^escaped/i for identifier detection: name must start with "escaped", so unescapedValue is no longer whitelisted.
    • Added invalid test cases for unescapedValue and escapeHtml(x).
  2. Diagnostic suggestion not recognized — added isRegexEscapeReplaceCall that recognises the standard .replace(/* pattern */, "\\$&") form as safe, so the suggested remediation in the message is now accepted by the rule (covering the safe_output_type_validator.cjs:24 case). Added a valid test case for this form.

Copilot AI requested a review from pelikhan July 30, 2026 10:22
@pelikhan
pelikhan merged commit 525e518 into main Jul 30, 2026
@pelikhan
pelikhan deleted the eslint-miner/require-escaped-regexp-interpolation-e7dd5b6bd69b8048 branch July 30, 2026 10:25
@github-actions

Copy link
Copy Markdown
Contributor Author

🎉 This pull request is included in a new release.

Release: v0.84.1

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants