Skip to content

Commit 408a9f4

Browse files
authored
Merge branch 'main' into 0.4.1
2 parents 2dab422 + ae8b8c4 commit 408a9f4

File tree

17 files changed

+4037
-25
lines changed

17 files changed

+4037
-25
lines changed

SECURITY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ We are committed to:
3636
We provide security updates for the following versions:
3737

3838
| Version | Supported |
39-
| ------- | ------------------ |
40-
| 0.3.x | :white_check_mark: |
39+
|---------|--------------------|
40+
| 0.4.x | :white_check_mark: |
41+
| 0.3.x | :x: |
4142
| 0.2.x | :x: |
4243
| 0.1.x | :x: |
4344

packages/backend/src/modules/query/service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,9 @@ export class QueryService {
134134
if (searchMode === 'substring') {
135135
// Substring search using ILIKE with trigram index (pg_trgm)
136136
// This finds text anywhere in the message, unlike fulltext which is word-based
137-
// Escape special LIKE characters (% and _) to prevent pattern manipulation
138-
const escapedQuery = q.replace(/[%_]/g, '\\$&');
137+
// Escape special LIKE characters (%, _, \) to prevent pattern manipulation
138+
// Backslash must be escaped first to avoid double-escaping
139+
const escapedQuery = q.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
139140
// Build the pattern and pass it as a parameter to Kysely
140141
const pattern = `%${escapedQuery}%`;
141142
query = query.where(
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { FingerprintService } from '../../../modules/exceptions/fingerprint-service.js';
3+
import type { ParsedException, StackFrame } from '../../../modules/exceptions/types.js';
4+
5+
describe('FingerprintService', () => {
6+
describe('generate', () => {
7+
it('should generate consistent fingerprints for same exception', () => {
8+
const exception: ParsedException = {
9+
exceptionType: 'Error',
10+
exceptionMessage: 'Test error',
11+
language: 'nodejs',
12+
rawStackTrace: 'Error: Test\n at test (/app/test.js:10:5)',
13+
frames: [
14+
{
15+
frameIndex: 0,
16+
filePath: '/app/src/handler.js',
17+
functionName: 'handleRequest',
18+
lineNumber: 42,
19+
columnNumber: 10,
20+
isAppCode: true,
21+
},
22+
],
23+
};
24+
25+
const fingerprint1 = FingerprintService.generate(exception);
26+
const fingerprint2 = FingerprintService.generate(exception);
27+
28+
expect(fingerprint1).toBe(fingerprint2);
29+
expect(fingerprint1).toHaveLength(64); // SHA-256 hex string
30+
});
31+
32+
it('should generate different fingerprints for different exception types', () => {
33+
const exception1: ParsedException = {
34+
exceptionType: 'TypeError',
35+
exceptionMessage: 'Test error',
36+
language: 'nodejs',
37+
rawStackTrace: '',
38+
frames: [
39+
{
40+
frameIndex: 0,
41+
filePath: '/app/handler.js',
42+
functionName: 'process',
43+
lineNumber: 10,
44+
isAppCode: true,
45+
},
46+
],
47+
};
48+
49+
const exception2: ParsedException = {
50+
...exception1,
51+
exceptionType: 'ReferenceError',
52+
};
53+
54+
const fingerprint1 = FingerprintService.generate(exception1);
55+
const fingerprint2 = FingerprintService.generate(exception2);
56+
57+
expect(fingerprint1).not.toBe(fingerprint2);
58+
});
59+
60+
it('should generate different fingerprints for different stack traces', () => {
61+
const exception1: ParsedException = {
62+
exceptionType: 'Error',
63+
exceptionMessage: 'Test',
64+
language: 'nodejs',
65+
rawStackTrace: '',
66+
frames: [
67+
{
68+
frameIndex: 0,
69+
filePath: '/app/handler.js',
70+
functionName: 'handleA',
71+
lineNumber: 10,
72+
isAppCode: true,
73+
},
74+
],
75+
};
76+
77+
const exception2: ParsedException = {
78+
...exception1,
79+
frames: [
80+
{
81+
frameIndex: 0,
82+
filePath: '/app/handler.js',
83+
functionName: 'handleB',
84+
lineNumber: 10,
85+
isAppCode: true,
86+
},
87+
],
88+
};
89+
90+
const fingerprint1 = FingerprintService.generate(exception1);
91+
const fingerprint2 = FingerprintService.generate(exception2);
92+
93+
expect(fingerprint1).not.toBe(fingerprint2);
94+
});
95+
96+
it('should ignore library code in fingerprint', () => {
97+
const exception1: ParsedException = {
98+
exceptionType: 'Error',
99+
exceptionMessage: 'Test',
100+
language: 'nodejs',
101+
rawStackTrace: '',
102+
frames: [
103+
{
104+
frameIndex: 0,
105+
filePath: '/app/node_modules/express/lib/router.js',
106+
functionName: 'Router.handle',
107+
lineNumber: 100,
108+
isAppCode: false,
109+
},
110+
{
111+
frameIndex: 1,
112+
filePath: '/app/src/handler.js',
113+
functionName: 'myHandler',
114+
lineNumber: 20,
115+
isAppCode: true,
116+
},
117+
],
118+
};
119+
120+
const exception2: ParsedException = {
121+
exceptionType: 'Error',
122+
exceptionMessage: 'Test',
123+
language: 'nodejs',
124+
rawStackTrace: '',
125+
frames: [
126+
{
127+
frameIndex: 0,
128+
filePath: '/app/node_modules/koa/lib/application.js',
129+
functionName: 'Application.handle',
130+
lineNumber: 200,
131+
isAppCode: false,
132+
},
133+
{
134+
frameIndex: 1,
135+
filePath: '/app/src/handler.js',
136+
functionName: 'myHandler',
137+
lineNumber: 20,
138+
isAppCode: true,
139+
},
140+
],
141+
};
142+
143+
// Same app code frames should produce same fingerprint despite different library frames
144+
const fingerprint1 = FingerprintService.generate(exception1);
145+
const fingerprint2 = FingerprintService.generate(exception2);
146+
147+
expect(fingerprint1).toBe(fingerprint2);
148+
});
149+
150+
it('should generate same fingerprint regardless of error message content', () => {
151+
const exception1: ParsedException = {
152+
exceptionType: 'ValidationError',
153+
exceptionMessage: 'Invalid email: user1@test.com',
154+
language: 'nodejs',
155+
rawStackTrace: '',
156+
frames: [
157+
{
158+
frameIndex: 0,
159+
filePath: '/app/validator.js',
160+
functionName: 'validate',
161+
lineNumber: 15,
162+
isAppCode: true,
163+
},
164+
],
165+
};
166+
167+
const exception2: ParsedException = {
168+
...exception1,
169+
exceptionMessage: 'Invalid email: user2@test.com',
170+
};
171+
172+
// Same stack trace, same exception type - should have same fingerprint
173+
const fingerprint1 = FingerprintService.generate(exception1);
174+
const fingerprint2 = FingerprintService.generate(exception2);
175+
176+
expect(fingerprint1).toBe(fingerprint2);
177+
});
178+
});
179+
180+
describe('generateFromFrames', () => {
181+
it('should generate fingerprint from frames directly', () => {
182+
const frames: StackFrame[] = [
183+
{
184+
frameIndex: 0,
185+
filePath: '/app/src/service.js',
186+
functionName: 'Service.process',
187+
lineNumber: 30,
188+
isAppCode: true,
189+
},
190+
];
191+
192+
const fingerprint = FingerprintService.generateFromFrames('Error', frames, 'nodejs');
193+
194+
expect(fingerprint).toHaveLength(64);
195+
});
196+
197+
it('should match generate() output', () => {
198+
const frames: StackFrame[] = [
199+
{
200+
frameIndex: 0,
201+
filePath: '/app/src/handler.js',
202+
functionName: 'handle',
203+
lineNumber: 10,
204+
isAppCode: true,
205+
},
206+
];
207+
208+
const exception: ParsedException = {
209+
exceptionType: 'Error',
210+
exceptionMessage: 'Test',
211+
language: 'nodejs',
212+
rawStackTrace: '',
213+
frames,
214+
};
215+
216+
const fingerprint1 = FingerprintService.generate(exception);
217+
const fingerprint2 = FingerprintService.generateFromFrames('Error', frames, 'nodejs');
218+
219+
expect(fingerprint1).toBe(fingerprint2);
220+
});
221+
222+
it('should work with unknown language using default normalization', () => {
223+
const frames: StackFrame[] = [
224+
{
225+
frameIndex: 0,
226+
filePath: '/app/unknown.xyz',
227+
functionName: 'process',
228+
lineNumber: 5,
229+
isAppCode: true,
230+
},
231+
];
232+
233+
const fingerprint = FingerprintService.generateFromFrames('Error', frames, 'unknown');
234+
235+
expect(fingerprint).toHaveLength(64);
236+
});
237+
});
238+
239+
describe('normalizeMessage', () => {
240+
it('should replace numbers with N', () => {
241+
const message = 'Error at line 42 column 15';
242+
const normalized = FingerprintService.normalizeMessage(message);
243+
244+
expect(normalized).toBe('Error at line N column N');
245+
});
246+
247+
it('should replace hex values (digits replaced first)', () => {
248+
// Note: numbers are replaced before hex, so 0x gets mangled to Nx
249+
// and the remaining letters don't match the 0x... pattern anymore
250+
const message = 'Memory address 0xabc is invalid';
251+
const normalized = FingerprintService.normalizeMessage(message);
252+
253+
// The 0 gets replaced by N first, then 0x[0-9a-f]+ can't match Nxabc
254+
expect(normalized).toBe('Memory address Nxabc is invalid');
255+
});
256+
257+
it('should replace single-quoted strings', () => {
258+
const message = "Cannot find property 'userId' on object";
259+
const normalized = FingerprintService.normalizeMessage(message);
260+
261+
expect(normalized).toBe("Cannot find property 'STRING' on object");
262+
});
263+
264+
it('should replace double-quoted strings', () => {
265+
const message = 'Invalid value "test@example.com" for email';
266+
const normalized = FingerprintService.normalizeMessage(message);
267+
268+
expect(normalized).toBe('Invalid value "STRING" for email');
269+
});
270+
271+
it('should replace array contents', () => {
272+
const message = 'Expected one of [1, 2, 3] but got 5';
273+
const normalized = FingerprintService.normalizeMessage(message);
274+
275+
expect(normalized).toBe('Expected one of [ARRAY] but got N');
276+
});
277+
278+
it('should replace object contents', () => {
279+
const message = 'Invalid config {host: localhost, port: 5432}';
280+
const normalized = FingerprintService.normalizeMessage(message);
281+
282+
expect(normalized).toBe('Invalid config {OBJECT}');
283+
});
284+
285+
it('should trim whitespace', () => {
286+
const message = ' Error with spaces ';
287+
const normalized = FingerprintService.normalizeMessage(message);
288+
289+
expect(normalized).toBe('Error with spaces');
290+
});
291+
292+
it('should handle multiple replacements', () => {
293+
const message = "Error 42: Invalid user 'john' at index [0]";
294+
const normalized = FingerprintService.normalizeMessage(message);
295+
296+
expect(normalized).toBe("Error N: Invalid user 'STRING' at index [ARRAY]");
297+
});
298+
299+
it('should handle empty message', () => {
300+
expect(FingerprintService.normalizeMessage('')).toBe('');
301+
});
302+
303+
it('should handle message with no dynamic values', () => {
304+
const message = 'Connection refused';
305+
expect(FingerprintService.normalizeMessage(message)).toBe('Connection refused');
306+
});
307+
});
308+
});

0 commit comments

Comments
 (0)