Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint-factory/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ module.exports = [
"gh-aw-custom/require-getexecoutput-exitcode-check": "warn",
"gh-aw-custom/prefer-actions-exec-over-child-process": "warn",
"gh-aw-custom/no-misplaced-error-code-definition": "warn",
"gh-aw-custom/require-finite-check-before-date-toisostring": "warn",
},
},
{
Expand Down
2 changes: 2 additions & 0 deletions eslint-factory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { requireFsChmodTryCatchRule } from "./rules/require-fs-chmod-try-catch";
import { noSingleCharStringReplaceRule } from "./rules/no-single-char-string-replace";
import { noAsyncForEachCallbackRule } from "./rules/no-async-foreach-callback";
import { noUnguardedAsyncTimerCallbackRule } from "./rules/no-unguarded-async-timer-callback";
import { requireFiniteCheckBeforeDateToIsoStringRule } from "./rules/require-finite-check-before-date-toisostring";

const plugin = {
meta: {
Expand Down Expand Up @@ -136,6 +137,7 @@ const plugin = {
"no-single-char-string-replace": noSingleCharStringReplaceRule,
"no-unguarded-async-timer-callback": noUnguardedAsyncTimerCallbackRule,
"no-async-foreach-callback": noAsyncForEachCallbackRule,
"require-finite-check-before-date-toisostring": requireFiniteCheckBeforeDateToIsoStringRule,
},
};

Expand Down
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();`,
],

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] 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)) {} to invalid and document the guard must dominate the call, otherwise this gap will regress silently as the rule evolves.

@copilot please address this.

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 {

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.

Guard-detection false negative: suppression ignores which property is checked.

collectIdentifierNames collapses a MemberExpression to just its root object identifier (data.reset → "data"), and hasGuard only compares these base names against the guard call's argument names. This means validating a different property on the same object silences the warning for the actually-unvalidated one:

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 (Number(header) * 1000 from rate-limit headers, data.reset * 1000), so the false negative is likely to hide genuine instances of the bug this rule is meant to catch. Consider comparing full property-access paths (e.g. data.reset vs data.other) rather than only root identifiers, or at minimum only match a guard when the full member expression text is identical to (or a subset of) the one used in the new Date(...) argument.

@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)) {

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.

This also suppresses on a guard for any one identifier inside the argument, which means partially validated expressions like new Date(ms + offset).toISOString() are treated as safe after checking only ms.

💡 Why this is a correctness bug

collectIdentifierNames() gathers every identifier used in the date expression, but hasGuard() returns true as soon as one guarded name overlaps. That misses real failures where the unchecked part is what makes the timestamp invalid:

function f(ms, offset) {
  if (!Number.isFinite(ms)) return "";
  return new Date(ms + offset).toISOString();
}

If offset is NaN or Infinity, this still throws RangeError, but the rule now considers it guarded. The guard logic needs to validate the full risky expression (or conservatively require all participating dynamic inputs to be covered), not just any shared identifier.

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);

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.

[/codebase-design] hasGuard has no dominance check: a guard that only executes in an unrelated branch, or textually appears after the risky new Date(...).toISOString() call, still suppresses the warning. The sibling rule in this same file (require-invalid-date-check-before-compare.ts) solves exactly this with guardDominatesComparison/isConditionalEdge, reusing that logic (or at least a range-order check: guard.range[1] <= call.range[0]) would materially cut false negatives here.

💡 Example false negative
function f(ms) {
  const s = new Date(ms).toISOString(); // not flagged
  if (Number.isFinite(ms)) {}
  return s;
}

hasGuard just scans the whole enclosing function body for any Number.isFinite/isNaN call sharing an identifier — it doesn't require the guard to run before the call, or to actually gate it (e.g. short-circuit / early-return). This mirrors the exact false-positive class that guardDominatesComparison was built to prevent in the companion rule.

@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;

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.

The matcher bails unless the receiver is an inline new Date(...), so the very common two-step form const d = new Date(ms); d.toISOString() is never reported.

💡 Why this materially weakens coverage

The 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 RangeError, but the rule only looks at new Date(...).toISOString() in a single expression. Please track variables initialized from new Date(...) and inspect later toISOString() / toJSON() calls on those values, similar to how require-invalid-date-check-before-compare follows date variables across statements.

const arg = receiver.arguments[0];
if (!arg || arg.type === AST_NODE_TYPES.SpreadElement || isTriviallyValidTime(arg)) return;
if (isInsideTryBlock(node) || hasGuard(node, arg)) return;

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.

This guard is too broad: any isFinite/isNaN call anywhere in the enclosing function suppresses the warning, even when that check runs after the serializer or only on a sibling branch.

💡 Why this blocks the rule

hasGuard() only answers "does a matching check exist somewhere in this function?", but this rule needs the same ordering/reachability logic as require-invalid-date-check-before-compare.

These both slip through today even though toISOString() can still throw before any protection happens:

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 require-invalid-date-check-before-compare instead of treating a mere in-function occurrence as sufficient.

context.report({
node: receiver,
messageId: "requireFiniteCheck",
data: { arg: sourceCode.getText(arg), method: callee.property.name },
});
},
};
},
});
Loading