Repository navigation
[eslint-miner] Add require-finite-check-before-date-toisostring rule #65825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { RuleTester } from "eslint"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { requireFiniteCheckBeforeDateToIsoStringRule } from "./require-finite-check-before-date-toisostring"; | ||
|
|
||
| const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 2022, sourceType: "commonjs" } }); | ||
|
|
||
| describe("require-finite-check-before-date-toisostring", () => { | ||
| it("uses the correct docs URL", () => { | ||
| expect(requireFiniteCheckBeforeDateToIsoStringRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#require-finite-check-before-date-toisostring"); | ||
| }); | ||
|
|
||
| it("valid and invalid cases", () => { | ||
| ruleTester.run("require-finite-check-before-date-toisostring", requireFiniteCheckBeforeDateToIsoStringRule, { | ||
| valid: [ | ||
| `new Date().toISOString();`, | ||
| `new Date(Date.now()).toISOString();`, | ||
| `new Date(Date.now() - WINDOW_MS).toISOString();`, | ||
| `new Date(0).toISOString();`, | ||
| `function f(ms) { if (!Number.isFinite(ms)) return ""; return new Date(ms).toISOString(); }`, | ||
| `function f(data) { if (Number.isNaN(data.reset)) return null; return new Date(data.reset * 1000).toISOString(); }`, | ||
| `function f(ms) { try { return new Date(ms).toISOString(); } catch { return ""; } }`, | ||
| `const d = new Date(ms); d.toISOString();`, | ||
| `new Date(ms).getTime();`, | ||
| ], | ||
| invalid: [ | ||
| { code: `function f(ms) { return new Date(ms).toISOString(); }`, errors: [{ messageId: "requireFiniteCheck" }] }, | ||
| { code: `const s = new Date(data.reset * 1000).toISOString();`, errors: [{ messageId: "requireFiniteCheck" }] }, | ||
| { code: `function f(ms) { try { x(); } catch { return new Date(ms).toJSON(); } }`, errors: [{ messageId: "requireFiniteCheck" }] }, | ||
| { code: `function f(a, b) { if (Number.isFinite(b)) {} return new Date(a).toISOString(); }`, errors: [{ messageId: "requireFiniteCheck" }] }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); | ||
|
|
||
| const SERIALIZERS = new Set(["toISOString", "toJSON"]); | ||
|
|
||
| const GUARD_NAMES = new Set(["isNaN", "isFinite"]); | ||
|
|
||
| function isDateNowCall(node: TSESTree.Node): boolean { | ||
| return ( | ||
| node.type === AST_NODE_TYPES.CallExpression && | ||
| node.callee.type === AST_NODE_TYPES.MemberExpression && | ||
| !node.callee.computed && | ||
| node.callee.object.type === AST_NODE_TYPES.Identifier && | ||
| node.callee.object.name === "Date" && | ||
| node.callee.property.type === AST_NODE_TYPES.Identifier && | ||
| node.callee.property.name === "now" | ||
| ); | ||
| } | ||
|
|
||
| /** True for expressions that cannot yield an invalid time value: literals, Date.now(), and arithmetic over those and UPPER_CASE constants. */ | ||
| function isTriviallyValidTime(node: TSESTree.Node): boolean { | ||
| if (node.type === AST_NODE_TYPES.Literal) return typeof node.value === "number"; | ||
| if (isDateNowCall(node)) return true; | ||
| if (node.type === AST_NODE_TYPES.Identifier) return /^[A-Z][A-Z0-9_]*$/.test(node.name); | ||
| if (node.type === AST_NODE_TYPES.BinaryExpression && ["+", "-", "*", "/"].includes(node.operator)) { | ||
| return isTriviallyValidTime(node.left) && isTriviallyValidTime(node.right); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function collectIdentifierNames(node: TSESTree.Node, names: Set<string>): void { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard-detection false negative: suppression ignores which property is checked.
function f(data) {
if (Number.isNaN(data.other)) return null; // guards data.other, NOT data.reset
return new Date(data.reset * 1000).toISOString(); // still unvalidated, but not flagged
}This is exactly the shape of the real-world evidence cited in the PR description ( @copilot please address this. |
||
| if (node.type === AST_NODE_TYPES.Identifier) names.add(node.name); | ||
| else if (node.type === AST_NODE_TYPES.MemberExpression) { | ||
| collectIdentifierNames(node.object, names); | ||
| } else if (node.type === AST_NODE_TYPES.BinaryExpression || node.type === AST_NODE_TYPES.LogicalExpression) { | ||
| collectIdentifierNames(node.left, names); | ||
| collectIdentifierNames(node.right, names); | ||
| } else if (node.type === AST_NODE_TYPES.CallExpression) { | ||
| node.arguments.forEach(arg => collectIdentifierNames(arg, names)); | ||
| } else if (node.type === AST_NODE_TYPES.ChainExpression) { | ||
| collectIdentifierNames(node.expression, names); | ||
| } | ||
| } | ||
|
|
||
| function isGuardCall(node: TSESTree.CallExpression): boolean { | ||
| const { callee } = node; | ||
| if (callee.type === AST_NODE_TYPES.Identifier) return GUARD_NAMES.has(callee.name); | ||
| return ( | ||
| callee.type === AST_NODE_TYPES.MemberExpression && | ||
| !callee.computed && | ||
| callee.object.type === AST_NODE_TYPES.Identifier && | ||
| callee.object.name === "Number" && | ||
| callee.property.type === AST_NODE_TYPES.Identifier && | ||
| GUARD_NAMES.has(callee.property.name) | ||
| ); | ||
| } | ||
|
|
||
| export const requireFiniteCheckBeforeDateToIsoStringRule = createRule({ | ||
| name: "require-finite-check-before-date-toisostring", | ||
|
|
||
| meta: { | ||
| type: "problem", | ||
| docs: { | ||
| description: "Require dynamic values passed to new Date(...) to be validated before calling toISOString()/toJSON(), which throw RangeError for invalid dates.", | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| requireFiniteCheck: "new Date({{arg}}).{{method}}() throws RangeError when the value is NaN, out of range, or an unparseable string. Validate the input with Number.isFinite()/Number.isNaN() or wrap the call in try/catch.", | ||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| const sourceCode = context.sourceCode; | ||
|
|
||
| function isInsideTryBlock(node: TSESTree.Node): boolean { | ||
| const ancestors = sourceCode.getAncestors(node); | ||
| for (let i = ancestors.length - 1; i >= 0; i--) { | ||
| const a = ancestors[i]; | ||
| if (a.type === AST_NODE_TYPES.FunctionDeclaration || a.type === AST_NODE_TYPES.FunctionExpression || a.type === AST_NODE_TYPES.ArrowFunctionExpression) return false; | ||
| if (a.type === AST_NODE_TYPES.TryStatement && a.handler != null) { | ||
| const child = ancestors[i + 1] ?? node; | ||
| if (child === a.block) return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function enclosingScopeNode(node: TSESTree.Node): TSESTree.Node { | ||
| const ancestors = sourceCode.getAncestors(node); | ||
| for (let i = ancestors.length - 1; i >= 0; i--) { | ||
| const a = ancestors[i]; | ||
| if (a.type === AST_NODE_TYPES.FunctionDeclaration || a.type === AST_NODE_TYPES.FunctionExpression || a.type === AST_NODE_TYPES.ArrowFunctionExpression) return a; | ||
| } | ||
| return ancestors[0]; | ||
| } | ||
|
|
||
| /** True when the enclosing function contains a finite/NaN check involving any identifier used in the date argument. */ | ||
| function hasGuard(node: TSESTree.Node, arg: TSESTree.Node): boolean { | ||
| const argNames = new Set<string>(); | ||
| collectIdentifierNames(arg, argNames); | ||
| if (argNames.size === 0) return false; | ||
| let found = false; | ||
| const visit = (n: TSESTree.Node): void => { | ||
| if (found) return; | ||
| if (n.type === AST_NODE_TYPES.CallExpression && isGuardCall(n)) { | ||
| const names = new Set<string>(); | ||
| n.arguments.forEach(a => collectIdentifierNames(a, names)); | ||
| for (const name of names) { | ||
| if (argNames.has(name)) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This also suppresses on a guard for any one identifier inside the argument, which means partially validated expressions like 💡 Why this is a correctness bug
function f(ms, offset) {
if (!Number.isFinite(ms)) return "";
return new Date(ms + offset).toISOString();
}If |
||
| found = true; | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| for (const key of Object.keys(n) as Array<keyof typeof n>) { | ||
| if (key === "parent") continue; | ||
| const value = n[key] as unknown; | ||
| if (Array.isArray(value)) value.forEach(v => v && typeof (v as TSESTree.Node).type === "string" && visit(v as TSESTree.Node)); | ||
| else if (value && typeof (value as TSESTree.Node).type === "string") visit(value as TSESTree.Node); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Example false negativefunction f(ms) {
const s = new Date(ms).toISOString(); // not flagged
if (Number.isFinite(ms)) {}
return s;
}
@copilot please address this. |
||
| } | ||
| }; | ||
| visit(enclosingScopeNode(node)); | ||
| return found; | ||
| } | ||
|
|
||
| return { | ||
| CallExpression(node: TSESTree.CallExpression) { | ||
| const callee = node.callee; | ||
| if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES.Identifier || !SERIALIZERS.has(callee.property.name)) return; | ||
| const receiver = callee.object; | ||
| if (receiver.type !== AST_NODE_TYPES.NewExpression || receiver.callee.type !== AST_NODE_TYPES.Identifier || receiver.callee.name !== "Date") return; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The matcher bails unless the receiver is an inline 💡 Why this materially weakens coverageThe new test suite explicitly marks this pattern as valid, which codifies a false negative for one of the easiest ways to write this bug: const d = new Date(ms);
return d.toISOString();That can still throw the same |
||
| const arg = receiver.arguments[0]; | ||
| if (!arg || arg.type === AST_NODE_TYPES.SpreadElement || isTriviallyValidTime(arg)) return; | ||
| if (isInsideTryBlock(node) || hasGuard(node, arg)) return; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard is too broad: any 💡 Why this blocks the rule
These both slip through today even though function f(ms) {
const out = new Date(ms).toISOString();
if (!Number.isFinite(ms)) return "";
return out;
}
function g(ms, enabled) {
if (enabled && !Number.isFinite(ms)) return "";
return new Date(ms).toISOString();
}Please record guards only when they dominate the serializer on every path, or reuse the existing control-flow approach from |
||
| context.report({ | ||
| node: receiver, | ||
| messageId: "requireFiniteCheck", | ||
| data: { arg: sourceCode.getText(arg), method: callee.property.name }, | ||
| }); | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] Test suite doesn't cover the false-negative class above (guard in a sibling/later statement). Add a test like
new Date(ms).toISOString(); if (Number.isFinite(ms)) {}toinvalidand document the guard must dominate the call, otherwise this gap will regress silently as the rule evolves.@copilot please address this.