Skip to content

Commit 7cee42a

Browse files
committed
feat(bug-detectors): simplify stack-based suppressions
Match stackPattern against normalized relevant stacks and print generic example snippets so users can adapt suppressions without brittle frame guessing.
1 parent 11c4ba3 commit 7cee42a

10 files changed

Lines changed: 160 additions & 100 deletions

File tree

docs/bug-detectors.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ The Path Traversal bug detector can be configured in the
3030

3131
- `ignore(rule)` - suppresses findings matching a file, function, or stack
3232
pattern. Multiple properties in one rule are matched as a logical AND.
33+
- `stackPattern` accepts either a string or a `RegExp` and is matched against
34+
the relevant stack trace with Jazzer.js frames removed and column numbers
35+
stripped.
3336

3437
Here is an example configuration in the
3538
[custom hooks](./fuzz-settings.md#customhooks--arraystring) file:
@@ -38,11 +41,13 @@ Here is an example configuration in the
3841
const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");
3942

4043
getBugDetectorConfiguration("path-traversal")?.ignore({
41-
filePattern: /src[\\/]safe-path-wrapper\.js$/,
42-
functionPattern: /^sanitizePath$/,
44+
stackPattern: "safe-path-wrapper.js:41",
4345
});
4446
```
4547
48+
Findings also print a generic example suppression snippet. Copy/paste it and
49+
adapt `stackPattern` to your stack trace.
50+
4651
_Disable with:_ `--disableBugDetectors=path-traversal` in CLI mode; or when
4752
using Jest in `.jazzerjsrc.json`:
4853
@@ -138,6 +143,9 @@ The detector can be configured in the
138143
or stack pattern.
139144
- `ignoreInvocation(rule)` - suppresses stage-2 findings matching a file,
140145
function, or stack pattern.
146+
- `stackPattern` accepts either a string or a `RegExp` and is matched against
147+
the relevant stack trace with Jazzer.js frames removed and column numbers
148+
stripped.
141149
142150
Here is an example configuration in the
143151
[custom hooks](./fuzz-settings.md#customhooks--arraystring) file:
@@ -147,12 +155,14 @@ const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");
147155

148156
getBugDetectorConfiguration("code-injection")
149157
?.ignoreAccess({
150-
filePattern: /handlebars[\\/]dist[\\/]cjs[\\/]runtime\.js$/,
151-
functionPattern: /^lookupProperty$/,
158+
stackPattern: "handlebars/runtime.js:87",
152159
})
153160
?.disableInvocationReporting();
154161
```
155162
163+
Findings print a generic example suppression snippet. Copy/paste it and adapt
164+
`stackPattern` to a stable substring or `RegExp` from the stack trace above.
165+
156166
_Disable with:_ `--disableBugDetectors=code-injection` in CLI mode; or when
157167
using Jest in `.jazzerjsrc.json`:
158168

packages/bug-detectors/internal/code-injection.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import {
2424
import { registerBeforeHook } from "@jazzer.js/hooking";
2525

2626
import { bugDetectorConfigurations } from "../configuration";
27+
2728
import {
28-
buildSuppressionSnippet,
29+
buildGenericSuppressionSnippet,
2930
captureStack,
3031
getRelevantStackLines,
31-
matchesIgnoreRules,
32-
parseOriginFrame,
3332
type IgnoreRule,
33+
matchesIgnoreRules,
3434
type OriginFrame,
35+
parseOriginFrame,
3536
} from "./finding-suppression";
3637

3738
const BASE_CANARY_NAME = "jaz_zer";
@@ -212,7 +213,6 @@ function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
212213
"Potential Code Injection (Canary Accessed)",
213214
`accessed canary: ${canaryName}`,
214215
accessStack,
215-
accessOrigin,
216216
"ignoreAccess",
217217
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
218218
),
@@ -229,7 +229,6 @@ function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
229229
"Confirmed Code Injection (Canary Invoked)",
230230
`invoked canary: ${canaryName}`,
231231
invocationStack,
232-
invocationOrigin,
233232
"ignoreInvocation",
234233
"If this execution sink is expected in your test environment, suppress it:",
235234
),
@@ -247,7 +246,6 @@ function buildFindingMessage(
247246
title: string,
248247
action: string,
249248
stack: string,
250-
originFrame: OriginFrame | undefined,
251249
suppressionMethod: "ignoreAccess" | "ignoreInvocation",
252250
hint: string,
253251
): string {
@@ -262,13 +260,9 @@ function buildFindingMessage(
262260
message.push(
263261
"",
264262
`[!] ${hint}`,
263+
" Example only: copy/paste it and adapt `stackPattern` to your needs.",
265264
"",
266-
buildSuppressionSnippet(
267-
"code-injection",
268-
suppressionMethod,
269-
originFrame,
270-
stack,
271-
),
265+
buildGenericSuppressionSnippet("code-injection", suppressionMethod),
272266
);
273267
return message.join("\n");
274268
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2026 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
buildGenericSuppressionSnippet,
19+
getNormalizedRelevantStack,
20+
matchesIgnoreRules,
21+
parseOriginFrame,
22+
} from "./finding-suppression";
23+
24+
describe("finding suppression", () => {
25+
const stack = [
26+
"Error",
27+
" at renderTemplate (C:\\repo\\tests\\bug-detectors\\sample.test.js:10:42)",
28+
" at handleRequest (/repo/src/server.js:20:7)",
29+
" at internal (/repo/jazzer.js/packages/core/core.js:30:1)",
30+
].join("\n");
31+
32+
test("normalizes relevant stacks for full-stack matching", () => {
33+
expect(getNormalizedRelevantStack(stack)).toBe(
34+
[
35+
"at renderTemplate (C:/repo/tests/bug-detectors/sample.test.js:10)",
36+
"at handleRequest (/repo/src/server.js:20)",
37+
].join("\n"),
38+
);
39+
});
40+
41+
test("matches string stack patterns against the normalized stack", () => {
42+
const originFrame = parseOriginFrame(stack);
43+
44+
expect(
45+
matchesIgnoreRules(
46+
[{ stackPattern: "sample.test.js:10" }],
47+
originFrame,
48+
stack,
49+
),
50+
).toBe(true);
51+
expect(
52+
matchesIgnoreRules(
53+
[{ stackPattern: "sample.test.js:11" }],
54+
originFrame,
55+
stack,
56+
),
57+
).toBe(false);
58+
});
59+
60+
test("matches regex stack patterns against the normalized stack", () => {
61+
const originFrame = parseOriginFrame(stack);
62+
63+
expect(
64+
matchesIgnoreRules(
65+
[{ stackPattern: /handleRequest \(\/repo\/src\/server\.js:20\)/ }],
66+
originFrame,
67+
stack,
68+
),
69+
).toBe(true);
70+
});
71+
72+
test("prints generic example snippets with optional chaining", () => {
73+
expect(
74+
buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"),
75+
).toContain('getBugDetectorConfiguration("code-injection")');
76+
expect(
77+
buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"),
78+
).toContain("?.ignoreInvocation({");
79+
expect(
80+
buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"),
81+
).toContain('stackPattern: "test.js:10"');
82+
});
83+
});

packages/bug-detectors/internal/finding-suppression.ts

Lines changed: 33 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ export interface IgnoreRule {
4040
*/
4141
functionPattern?: RegExp;
4242
/**
43-
* A regular expression matching the raw stack trace string.
44-
* Use this as a fallback if file or function names are minified or missing.
43+
* A string or regular expression matching the normalized relevant stack trace.
44+
* Use this when you want to match the full call chain rather than one frame.
45+
* @example "src/templates.js:41"
46+
* @example /renderTemplate.*handleRequest/s
4547
*/
46-
stackPattern?: RegExp;
48+
stackPattern?: string | RegExp;
4749
}
4850

4951
export type OriginFrame = {
@@ -60,39 +62,35 @@ export function matchesIgnoreRules(
6062
return rules.some((rule) => matchesIgnoreRule(rule, originFrame, stack));
6163
}
6264

63-
export function buildSuppressionSnippet(
65+
export function buildGenericSuppressionSnippet(
6466
detectorName: string,
6567
suppressionMethod: string,
66-
originFrame: OriginFrame | undefined,
67-
stack: string,
6868
): string {
69-
const ruleLines: string[] = [];
70-
const filePattern =
71-
originFrame?.filePath && buildFilePattern(originFrame.filePath);
72-
if (filePattern) {
73-
ruleLines.push(`filePattern: ${filePattern}`);
74-
}
75-
if (originFrame?.functionName) {
76-
ruleLines.push(
77-
`functionPattern: ${buildExactRegex(originFrame.functionName)}`,
78-
);
79-
}
80-
if (ruleLines.length === 0) {
81-
ruleLines.push(`stackPattern: ${buildStackPattern(stack)}`);
82-
}
83-
8469
return [
8570
'const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");',
8671
"",
72+
"// Example only: adapt `stackPattern` to the stack trace above.",
8773
`getBugDetectorConfiguration("${detectorName}")`,
88-
` .${suppressionMethod}({`,
89-
...ruleLines.map(
90-
(line, index) => ` ${line}${index < ruleLines.length - 1 ? "," : ""}`,
91-
),
74+
` ?.${suppressionMethod}({`,
75+
' stackPattern: "test.js:10",',
9276
" });",
9377
].join("\n");
9478
}
9579

80+
export function getNormalizedRelevantStack(stack: string): string {
81+
return getRelevantStackLines(stack)
82+
.map((line) => normalizeStackLine(line))
83+
.join("\n");
84+
}
85+
86+
function normalizeStackLine(line: string): string {
87+
return line
88+
.replace(/\\/g, "/")
89+
.replace(/:(\d+):\d+(?=[)\s]|$)/g, ":$1")
90+
.replace(/\s+/g, " ")
91+
.trim();
92+
}
93+
9694
export function captureStack(): string {
9795
return new Error().stack ?? "";
9896
}
@@ -138,7 +136,11 @@ function matchesIgnoreRule(
138136
originFrame: OriginFrame | undefined,
139137
stack: string,
140138
): boolean {
141-
if (rule.stackPattern && !matchesPattern(rule.stackPattern, stack)) {
139+
const normalizedStack = getNormalizedRelevantStack(stack);
140+
if (
141+
rule.stackPattern &&
142+
!matchesStackPattern(rule.stackPattern, normalizedStack)
143+
) {
142144
return false;
143145
}
144146
if (rule.filePattern) {
@@ -160,43 +162,6 @@ function matchesIgnoreRule(
160162
return Boolean(rule.stackPattern || rule.filePattern || rule.functionPattern);
161163
}
162164

163-
function buildFilePattern(filePath: string): string | undefined {
164-
if (filePath.startsWith("node:")) {
165-
return undefined;
166-
}
167-
168-
const normalized = filePath.replace(/\\/g, "/");
169-
let parts = normalized.split("/").filter(Boolean);
170-
if (parts.length === 0) {
171-
return undefined;
172-
}
173-
if (/^[A-Za-z]:$/.test(parts[0])) {
174-
parts = parts.slice(1);
175-
}
176-
177-
const nodeModulesIndex = parts.indexOf("node_modules");
178-
if (nodeModulesIndex !== -1) {
179-
parts = parts.slice(nodeModulesIndex + 1);
180-
} else {
181-
parts = parts.slice(-Math.min(2, parts.length));
182-
}
183-
184-
return `/${parts.map(escapeRegex).join("[\\\\/]")}$/`;
185-
}
186-
187-
function buildExactRegex(text: string): string {
188-
return `/^${escapeRegex(text)}$/`;
189-
}
190-
191-
function buildStackPattern(stack: string): string {
192-
const stackLine = getRelevantStackLines(stack)[0] ?? " at <unknown>";
193-
const normalized = stackLine
194-
.replace(/:\d+:\d+/g, "")
195-
.replace(/^\s*at\s+/, "")
196-
.trim();
197-
return `/${escapeRegex(normalized)}/`;
198-
}
199-
200165
function normalizeFunctionName(functionName: string): string {
201166
const trimmed = functionName.trim();
202167
if (trimmed.length === 0 || trimmed === "<anonymous>") {
@@ -233,6 +198,9 @@ function matchesPattern(pattern: RegExp, value: string): boolean {
233198
return pattern.test(value);
234199
}
235200

236-
function escapeRegex(value: string): string {
237-
return value.replace(/[|\\{}()[\]^$+*?./]/g, "\\$&");
201+
function matchesStackPattern(pattern: string | RegExp, value: string): boolean {
202+
if (typeof pattern === "string") {
203+
return value.includes(pattern.replace(/\\/g, "/"));
204+
}
205+
return matchesPattern(pattern, value);
238206
}

packages/bug-detectors/internal/path-traversal.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ import {
2121
import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking";
2222

2323
import { bugDetectorConfigurations } from "../configuration";
24+
2425
import {
25-
buildSuppressionSnippet,
26+
buildGenericSuppressionSnippet,
2627
captureStack,
2728
getRelevantStackLines,
28-
matchesIgnoreRules,
29-
parseOriginFrame,
3029
type IgnoreRule,
30+
matchesIgnoreRules,
3131
type OriginFrame,
32+
parseOriginFrame,
3233
} from "./finding-suppression";
3334

3435
/**
@@ -257,7 +258,7 @@ function detectFindingAndGuideFuzzing(
257258
return;
258259
}
259260
reportAndThrowFinding(
260-
buildFindingMessage(functionName, argument, stack, originFrame),
261+
buildFindingMessage(functionName, argument, stack),
261262
false,
262263
);
263264
}
@@ -269,7 +270,6 @@ function buildFindingMessage(
269270
functionName: string,
270271
argument: string,
271272
stack: string,
272-
originFrame: OriginFrame | undefined,
273273
): string {
274274
const relevantStackLines = getRelevantStackLines(stack).slice(
275275
0,
@@ -285,8 +285,9 @@ function buildFindingMessage(
285285
message.push(
286286
"",
287287
"[!] If this path is expected in your test environment, suppress it:",
288+
" Example only: copy/paste it and adapt `stackPattern` to your needs.",
288289
"",
289-
buildSuppressionSnippet("path-traversal", "ignore", originFrame, stack),
290+
buildGenericSuppressionSnippet("path-traversal", "ignore"),
290291
);
291292
return message.join("\n");
292293
}

0 commit comments

Comments
 (0)