Skip to content

Commit 5c8cb7c

Browse files
niemyjskiclaude
andcommitted
Use crypto.getRandomValues for guid/randomNumber with Math.random fallback, replace parseVersion regex with linear-time parser
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e3def88 commit 5c8cb7c

File tree

3 files changed

+189
-10
lines changed

3 files changed

+189
-10
lines changed

packages/core/src/Utils.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,39 @@ export function getCookies(cookies: string, exclusions?: string[]): Record<strin
2727
return !isEmpty(result) ? result : null;
2828
}
2929

30+
function getSecureCrypto(): Crypto | null {
31+
const { crypto } = globalThis;
32+
if (!crypto || typeof crypto.getRandomValues !== "function") {
33+
return null;
34+
}
35+
36+
return crypto;
37+
}
38+
39+
function toHex(bytes: Uint8Array, start: number, length: number): string {
40+
let result = "";
41+
for (let index = start; index < start + length; index++) {
42+
result += bytes[index].toString(16).padStart(2, "0");
43+
}
44+
45+
return result;
46+
}
47+
3048
export function guid(): string {
49+
const crypto = getSecureCrypto();
50+
if (crypto) {
51+
if (typeof crypto.randomUUID === "function") {
52+
return crypto.randomUUID();
53+
}
54+
55+
const bytes = new Uint8Array(16);
56+
crypto.getRandomValues(bytes);
57+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
58+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
59+
60+
return `${toHex(bytes, 0, 4)}-${toHex(bytes, 4, 2)}-${toHex(bytes, 6, 2)}-${toHex(bytes, 8, 2)}-${toHex(bytes, 10, 6)}`;
61+
}
62+
3163
function s4() {
3264
return Math.floor((1 + Math.random()) * 0x10000)
3365
.toString(16)
@@ -37,15 +69,101 @@ export function guid(): string {
3769
return s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4();
3870
}
3971

72+
function isDigitCode(code: number): boolean {
73+
return code >= 48 && code <= 57;
74+
}
75+
76+
function isVersionIdentifierCode(code: number): boolean {
77+
return isDigitCode(code) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 45;
78+
}
79+
80+
function readDigits(input: string, start: number): number {
81+
let index = start;
82+
while (index < input.length && isDigitCode(input.charCodeAt(index))) {
83+
index++;
84+
}
85+
86+
return index > start ? index : -1;
87+
}
88+
89+
function readVersionIdentifiers(input: string, start: number): number {
90+
let index = start;
91+
while (index < input.length) {
92+
const segmentStart = index;
93+
while (index < input.length && isVersionIdentifierCode(input.charCodeAt(index))) {
94+
index++;
95+
}
96+
97+
if (index === segmentStart) {
98+
return -1;
99+
}
100+
101+
if (input[index] !== ".") {
102+
return index;
103+
}
104+
105+
index++;
106+
}
107+
108+
return index;
109+
}
110+
40111
export function parseVersion(source: string): string | null {
41112
if (!source) {
42113
return null;
43114
}
44115

45-
const versionRegex = /(v?((\d+)\.(\d+)(\.(\d+))?)(?:-([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?)/;
46-
const matches = versionRegex.exec(source);
47-
if (matches && matches.length > 0) {
48-
return matches[0];
116+
for (let index = 0; index < source.length; ) {
117+
const start = index;
118+
let cursor = index;
119+
120+
if (source[cursor] === "v") {
121+
if (!isDigitCode(source.charCodeAt(cursor + 1))) {
122+
index++;
123+
continue;
124+
}
125+
126+
cursor++;
127+
} else if (!isDigitCode(source.charCodeAt(cursor))) {
128+
index++;
129+
continue;
130+
}
131+
132+
const majorEnd = readDigits(source, cursor);
133+
if (source[majorEnd] !== ".") {
134+
index = majorEnd;
135+
continue;
136+
}
137+
138+
const minorEnd = readDigits(source, majorEnd + 1);
139+
if (minorEnd === -1) {
140+
index = majorEnd + 1;
141+
continue;
142+
}
143+
144+
let end = minorEnd;
145+
if (source[end] === ".") {
146+
const patchEnd = readDigits(source, end + 1);
147+
if (patchEnd !== -1) {
148+
end = patchEnd;
149+
}
150+
}
151+
152+
if (source[end] === "-") {
153+
const preReleaseEnd = readVersionIdentifiers(source, end + 1);
154+
if (preReleaseEnd !== -1) {
155+
end = preReleaseEnd;
156+
}
157+
}
158+
159+
if (source[end] === "+") {
160+
const buildEnd = readVersionIdentifiers(source, end + 1);
161+
if (buildEnd !== -1) {
162+
end = buildEnd;
163+
}
164+
}
165+
166+
return source.slice(start, end);
49167
}
50168

51169
return null;
@@ -73,6 +191,14 @@ export function parseQueryString(query: string, exclusions?: string[]): Record<s
73191
}
74192

75193
export function randomNumber(): number {
194+
const crypto = getSecureCrypto();
195+
if (crypto) {
196+
const values = new Uint32Array(2);
197+
crypto.getRandomValues(values);
198+
199+
return (values[0] & 0x1fffff) * 0x100000000 + values[1];
200+
}
201+
76202
return Math.floor(Math.random() * 9007199254740992);
77203
}
78204

@@ -86,16 +212,15 @@ export function isMatch(input: string | undefined, patterns: string[], ignoreCas
86212
return false;
87213
}
88214

89-
const trim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;
90-
input = (ignoreCase ? input.toLowerCase() : input).replace(trim, "");
215+
input = (ignoreCase ? input.toLowerCase() : input).trim();
91216

92217
return (patterns || []).some((pattern) => {
93218
if (typeof pattern !== "string") {
94219
return false;
95220
}
96221

97222
if (pattern) {
98-
pattern = (ignoreCase ? pattern.toLowerCase() : pattern).replace(trim, "");
223+
pattern = (ignoreCase ? pattern.toLowerCase() : pattern).trim();
99224
}
100225

101226
if (!pattern) {

packages/core/test/ExceptionlessClient.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test } from "vitest";
1+
import { describe, expect, test, vi } from "vitest";
22

33
import { ExceptionlessClient } from "#/ExceptionlessClient.js";
44
import { KnownEventDataKeys } from "#/models/Event.js";
@@ -92,6 +92,24 @@ describe("ExceptionlessClient", () => {
9292
expect(client.config.serverUrl).toBe("https://localhost:5100");
9393
});
9494

95+
test("should create session identifiers without Math.random", async () => {
96+
const client = new ExceptionlessClient();
97+
client.config.apiKey = "UNIT_TEST_API_KEY";
98+
client.config.useSessions(false, 60000, true);
99+
100+
const mathRandomSpy = vi.spyOn(Math, "random").mockImplementation(() => {
101+
throw new Error("Math.random should not be used");
102+
});
103+
104+
try {
105+
const context = await client.submitSessionStart();
106+
expect(client.config.currentSessionIdentifier).toMatch(/^[0-9a-f]{32}$/);
107+
expect(context.event.reference_id).toBe(client.config.currentSessionIdentifier);
108+
} finally {
109+
mathRandomSpy.mockRestore();
110+
}
111+
});
112+
95113
function createException(): ReferenceError {
96114
function throwError() {
97115
throw new ReferenceError("This is a test");

packages/core/test/Utils.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, expect, test } from "vitest";
1+
import { describe, expect, test, vi } from "vitest";
22

3-
import { endsWith, isEmpty, isMatch, parseVersion, prune, startsWith, stringify, toBoolean } from "#/Utils.js";
3+
import { endsWith, guid, isEmpty, isMatch, parseVersion, prune, randomNumber, startsWith, stringify, toBoolean } from "#/Utils.js";
44

55
describe("Utils", () => {
66
function getObjectWithInheritedProperties(): unknown {
@@ -624,6 +624,38 @@ describe("Utils", () => {
624624
expect(parseVersion("https://cdnjs.cloudflare.com/BLAH/BLAH.min.js")).toBeNull();
625625
});
626626

627+
test("should parse semantic version labels without regex backtracking", () => {
628+
expect(parseVersion("release/v1.2.3-rc.1+build.5/index.js")).toBe("v1.2.3-rc.1+build.5");
629+
expect(parseVersion("release-1.2.3-.js")).toBe("1.2.3");
630+
});
631+
632+
test("should generate guids without Math.random", () => {
633+
const mathRandomSpy = vi.spyOn(Math, "random").mockImplementation(() => {
634+
throw new Error("Math.random should not be used");
635+
});
636+
637+
try {
638+
expect(guid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
639+
} finally {
640+
mathRandomSpy.mockRestore();
641+
}
642+
});
643+
644+
test("should generate random numbers without Math.random", () => {
645+
const mathRandomSpy = vi.spyOn(Math, "random").mockImplementation(() => {
646+
throw new Error("Math.random should not be used");
647+
});
648+
649+
try {
650+
const value = randomNumber();
651+
expect(Number.isSafeInteger(value)).toBe(true);
652+
expect(value).toBeGreaterThanOrEqual(0);
653+
expect(value).toBeLessThan(9007199254740992);
654+
} finally {
655+
mathRandomSpy.mockRestore();
656+
}
657+
});
658+
627659
describe("isEmpty", () => {
628660
const emptyValues = {
629661
undefined: undefined,
@@ -721,6 +753,10 @@ describe("Utils", () => {
721753
test('input: myPassword patterns [" * pAssword * "]', () => {
722754
expect(isMatch("myPassword", ["*pAssword*"])).toBe(true);
723755
});
756+
757+
test("should trim unicode whitespace without regex replacement", () => {
758+
expect(isMatch("\uFEFF myPassword \u00A0", ["\uFEFF *password* \u00A0"])).toBe(true);
759+
});
724760
});
725761

726762
describe("startsWith", () => {

0 commit comments

Comments
 (0)