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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-05-23 - [Version String Parsing Vulnerability]
**Vulnerability:** The version comparison logic in `src/index.ts` failed to correctly parse version strings prefixed with `v` (e.g., `"v10.0.0"`). The `Number()` function would return `NaN` for segments like `"v10"`, leading to incorrect comparison results (often evaluating as equal due to `NaN` comparison behavior).
**Learning:** Naive number parsing of version strings can be dangerous. Standard semver libraries handle this, but custom implementations must be careful. Specifically, `NaN` in comparisons can lead to "fail open" scenarios where a lower version is considered "at least" a higher version because the check returns false for both `<` and `>`, falling through to equality or default cases.
**Prevention:** Always sanitize version strings (strip non-numeric prefixes) before parsing. When implementing custom version comparison, handle `NaN` explicitly or use a robust library. Ensure inputs are validated or normalized.
40 changes: 4 additions & 36 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@

"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],

"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],

"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],

"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],

Expand Down Expand Up @@ -168,7 +168,7 @@

"@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],

"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],

"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],

Expand Down Expand Up @@ -606,7 +606,7 @@

"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],

"unrun": ["unrun@0.2.22", "", { "dependencies": { "rolldown": "1.0.0-beta.58" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-vlQce4gTLNyCZxGylEQXGG+fSrrEFWiM/L8aghtp+t6j8xXh+lmsBtQJknG7ZSvv7P+/MRgbQtHWHBWk981uTg=="],
"unrun": ["unrun@0.2.21", "", { "dependencies": { "rolldown": "1.0.0-beta.57" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-VuwI4YKtwBpDvM7hCEop2Im/ezS82dliqJpkh9pvS6ve8HcUsBDvESHxMmUfImXR03GkmfdDynyrh/pUJnlguw=="],

"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],

Expand Down Expand Up @@ -650,38 +650,6 @@

"unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],

"unrun/rolldown": ["rolldown@1.0.0-beta.58", "", { "dependencies": { "@oxc-project/types": "=0.106.0", "@rolldown/pluginutils": "1.0.0-beta.58" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.58", "@rolldown/binding-darwin-arm64": "1.0.0-beta.58", "@rolldown/binding-darwin-x64": "1.0.0-beta.58", "@rolldown/binding-freebsd-x64": "1.0.0-beta.58", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.58", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.58", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.58", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.58", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.58", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.58", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.58", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.58", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.58" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-v1FCjMZCan7f+xGAHBi+mqiE4MlH7I+SXEHSQSJoMOGNNB2UYtvMiejsq9YuUOiZjNeUeV/a21nSFbrUR+4ZCQ=="],

"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],

"unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.106.0", "", {}, "sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg=="],

"unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.58", "", { "os": "android", "cpu": "arm64" }, "sha512-mWj5eE4Qc8TbPdGGaaLvBb9XfDPvE1EmZkJQgiGKwchkWH4oAJcRAKMTw7ZHnb1L+t7Ah41sBkAecaIsuUgsug=="],

"unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.58", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wFxUymI/5R8bH8qZFYDfAxAN9CyISEIYke+95oZPiv6EWo88aa5rskjVcCpKA532R+klFmdqjbbaD56GNmTF4Q=="],

"unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.58", "", { "os": "darwin", "cpu": "x64" }, "sha512-ybp3MkPj23VDV9PhtRwdU5qrGhlViWRV5BjKwO6epaSlUD5lW0WyY+roN3ZAzbma/9RrMTgZ/a/gtQq8YXOcqw=="],

"unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.58", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Evxj3yh7FWvyklUYZa0qTVT9N2zX9TPDqGF056hl8hlCZ9/ndQ2xMv6uw9PD1VlLpukbsqL+/C6M0qwipL0QMg=="],

"unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58", "", { "os": "linux", "cpu": "arm" }, "sha512-tYeXprDOrEgVHUbPXH6MPso4cM/c6RTkmJNICMQlYdki4hGMh92aj3yU6CKs+4X5gfG0yj5kVUw/L4M685SYag=="],

"unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58", "", { "os": "linux", "cpu": "arm64" }, "sha512-N78vmZzP6zG967Ohr+MasCjmKtis0geZ1SOVmxrA0/bklTQSzH5kHEjW5Qn+i1taFno6GEre1E40v0wuWsNOQw=="],

"unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.58", "", { "os": "linux", "cpu": "arm64" }, "sha512-l+p4QVtG72C7wI2SIkNQw/KQtSjuYwS3rV6AKcWrRBF62ClsFUcif5vLaZIEbPrCXu5OFRXigXFJnxYsVVZqdQ=="],

"unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.58", "", { "os": "linux", "cpu": "x64" }, "sha512-urzJX0HrXxIh0FfxwWRjfPCMeInU9qsImLQxHBgLp5ivji1EEUnOfux8KxPPnRQthJyneBrN2LeqUix9DYrNaQ=="],

"unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.58", "", { "os": "linux", "cpu": "x64" }, "sha512-7ijfVK3GISnXIwq/1FZo+KyAUJjL3kWPJ7rViAL6MWeEBhEgRzJ0yEd9I8N9aut8Y8ab+EKFJyRNMWZuUBwQ0A=="],

"unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.58", "", { "os": "none", "cpu": "arm64" }, "sha512-/m7sKZCS+cUULbzyJTIlv8JbjNohxbpAOA6cM+lgWgqVzPee3U6jpwydrib328JFN/gF9A99IZEnuGYqEDJdww=="],

"unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.58", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-6SZk7zMgv+y3wFFQ9qE5P9NnRHcRsptL1ypmudD26PDY+PvFCvfHRkJNfclWnvacVGxjowr7JOL3a9fd1wWhUw=="],

"unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58", "", { "os": "win32", "cpu": "arm64" }, "sha512-sFqfYPnBZ6xBhMkadB7UD0yjEDRvs7ipR3nCggblN+N4ODCXY6qhg/bKL39+W+dgQybL7ErD4EGERVbW9DAWvg=="],

"unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.58", "", { "os": "win32", "cpu": "x64" }, "sha512-AnFWJdAqB8+IDPcGrATYs67Kik/6tnndNJV2jGRmwlbeNiQQ8GhRJU8ETRlINfII0pqi9k4WWLnb00p1QCxw/Q=="],

"unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.58", "", {}, "sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w=="],
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "0.18.2",
"@biomejs/biome": "^2.3.11",
"@biomejs/biome": "2.3.11",
"@changesets/cli": "2.29.8",
"@total-typescript/tsconfig": "1.0.4",
"@types/node": "24.10.4",
Expand Down
40 changes: 23 additions & 17 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ describe("node-version", () => {
expect(typeof v.build).toBe("string");
});

test("object should have exactly 16 properties", () => {
expect(Object.keys(version)).toHaveLength(16);
expect(Object.keys(getVersion())).toHaveLength(16);
test("object should have exactly 15 properties", () => {
expect(Object.keys(version)).toHaveLength(15);
expect(Object.keys(getVersion())).toHaveLength(15);
});

test("original property should start with v", () => {
Expand Down Expand Up @@ -300,6 +300,26 @@ describe("node-version", () => {
expect(v.isEOL).toBe(false);
});

test("should return true for very old version (Node 16)", () => {
vi.setSystemTime(new Date("2026-01-01"));
mockVersion.node = "16.0.0";
const v = getVersion();
expect(v.isEOL).toBe(true);
});

test("should return true for untracked old version (Node 12)", () => {
mockVersion.node = "12.0.0";
const v = getVersion();
expect(v.isEOL).toBe(true);
});

test("should return true for very old version (Node 0.10)", () => {
vi.setSystemTime(new Date("2026-01-01"));
mockVersion.node = "0.10.0";
const v = getVersion();
expect(v.isEOL).toBe(true);
});

test("should handle version 18 EOL", () => {
vi.setSystemTime(new Date("2025-05-01"));
mockVersion.node = "18.0.0"; // EOL is 2025-04-30
Expand All @@ -315,19 +335,5 @@ describe("node-version", () => {
expect(EOL_DATES).toHaveProperty(currentMajor);
}
});

test("should have eolDate property", () => {
mockVersion.node = "20.10.0";
const v = getVersion();
expect(v.eolDate).toBeDefined();
expect(v.eolDate).toBeInstanceOf(Date);
expect(v.eolDate?.toISOString().split("T")[0]).toBe("2026-04-30");
});

test("should have undefined eolDate for unknown version", () => {
mockVersion.node = "99.0.0";
const v = getVersion();
expect(v.eolDate).toBeUndefined();
});
});
});
86 changes: 33 additions & 53 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,69 +9,20 @@ import type { NodeVersion } from "./types.js";

export type { NodeVersion };

/**
* End-of-Life dates for Node.js major versions.
*/
export const EOL_DATES: Record<string, string> = {
"14": "2023-04-30",
"16": "2023-09-11",
"18": "2025-04-30",
"20": "2026-04-30",
"22": "2027-04-30",
"24": "2028-04-30",
};

/**
* Check if a major version is EOL.
*/
const checkEOL = (major: string): boolean => {
const eolDate = EOL_DATES[major];
if (!eolDate) return false;
return new Date() > new Date(eolDate);
};

/**
* Get Node current version.
*
* @returns {NodeVersion} An object containing detailed version information, comparisons, and LTS/EOL status.
*
* @example
* import { version } from 'node-version';
* console.log(version.original); // 'v20.10.0'
* if (version.isLTS) {
* console.log('Running on LTS!');
* }
*/
export const getVersion = (): NodeVersion => {
const nodeVersion = versions?.node ?? "0.0.0";
const split = nodeVersion.split(".");
// Pre-calculate numeric version parts for faster comparison
const nodeVersionParts = split.map((s) => Number(s) || 0);
const major = split[0] || "0";
const eolString = EOL_DATES[major];

/**
* Compare the current node version with a target version string.
*/
const compareTo = (target: string): number => {
if (target !== target.trim() || target.length === 0) {
return NaN;
}

const stripped = target.replace(/^v/i, "");

if (stripped.length === 0) {
return NaN;
}

const s2 = stripped.split(".");

for (const segment of s2) {
if (segment === "" || !/^\d+$/.test(segment)) {
return NaN;
}
}

const s2 = target.replace(/^v/i, "").split(".");
const len = Math.max(nodeVersionParts.length, s2.length);

for (let i = 0; i < len; i++) {
Expand All @@ -88,7 +39,7 @@ export const getVersion = (): NodeVersion => {
original: `v${nodeVersion}`,
short: `${split[0] || "0"}.${split[1] || "0"}`,
long: nodeVersion,
major: major,
major: split[0] || "0",
minor: split[1] || "0",
build: split[2] || "0",
isAtLeast: (version: string): boolean => {
Expand All @@ -108,12 +59,41 @@ export const getVersion = (): NodeVersion => {
},
isLTS: !!release.lts,
ltsName: String(release.lts || "") || undefined,
isEOL: checkEOL(major),
eolDate: eolString ? new Date(eolString) : undefined,
isEOL: checkEOL(split[0] || "0"),
toString: () => `v${nodeVersion}`,
};
};

/**
* End-of-Life dates for Node.js major versions.
*/
export const EOL_DATES: Record<string, string> = {
"18": "2025-04-30",
"20": "2026-04-30",
"22": "2027-04-30",
"24": "2028-04-30",
};

/**
* Calculate the minimum version tracked.
*/
const MIN_TRACKED_MAJOR = Math.min(...Object.keys(EOL_DATES).map(Number));

/**
* Check if a major version is EOL.
*/
const checkEOL = (major: string): boolean => {
const majorNum = Number(major);
// If it's a valid number and less than the minimum tracked version, it's EOL.
if (!Number.isNaN(majorNum) && majorNum < MIN_TRACKED_MAJOR) {
return true;
}

const eolDate = EOL_DATES[major];
if (!eolDate) return false;
return new Date() > new Date(eolDate);
};

/**
* Node version information.
*/
Expand Down
66 changes: 0 additions & 66 deletions src/security.test.ts

This file was deleted.

10 changes: 0 additions & 10 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,39 +35,34 @@ export interface NodeVersion {
/**
* Check if the current Node version is at least the specified version.
* @param version The version to compare against (e.g., '20.0.0').
* @returns {boolean} True if current version >= target version.
* @example
* version.isAtLeast('18.0.0'); // true if current is 20.0.0
*/
isAtLeast(version: string): boolean;
/**
* Check if the current Node version matches the specified version.
* @param version The version to compare against (e.g., '20.0.0').
* @returns {boolean} True if current version === target version.
* @example
* version.is('20.0.0'); // true if current is 20.0.0
*/
is(version: string): boolean;
/**
* Check if the current Node version is strictly greater than the specified version.
* @param version The version to compare against (e.g., '20.0.0').
* @returns {boolean} True if current version > target version.
* @example
* version.isAbove('18.0.0'); // true if current is 20.0.0
*/
isAbove(version: string): boolean;
/**
* Check if the current Node version is strictly less than the specified version.
* @param version The version to compare against (e.g., '20.0.0').
* @returns {boolean} True if current version < target version.
* @example
* version.isBelow('22.0.0'); // true if current is 20.0.0
*/
isBelow(version: string): boolean;
/**
* Check if the current Node version is at most the specified version.
* @param version The version to compare against (e.g., '20.0.0').
* @returns {boolean} True if current version <= target version.
* @example
* version.isAtMost('22.0.0'); // true if current is 20.0.0
*/
Expand All @@ -84,11 +79,6 @@ export interface NodeVersion {
* Check if the current version is considered End-of-Life (EOL).
*/
isEOL: boolean;
/**
* The date when this major version becomes End-of-Life.
* Undefined if the EOL date is not known (e.g., for very old or future versions not yet in the map).
*/
eolDate: Date | undefined;
/**
* Returns the original version string.
*/
Expand Down