Skip to content

Commit 93722f1

Browse files
committed
feat(code-injection): add stack suppressions
Let users disable or ignore expected canary access and invocation findings using the shown-stack suppression rules.
1 parent a0570e2 commit 93722f1

8 files changed

Lines changed: 380 additions & 12 deletions

File tree

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

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,93 @@ import {
2525
} from "@jazzer.js/core";
2626
import { registerBeforeHook } from "@jazzer.js/hooking";
2727

28+
import { bugDetectorConfigurations } from "../configuration";
2829
import { ensureCanary } from "../shared/code-injection-canary";
30+
import {
31+
buildGenericSuppressionSnippet,
32+
captureStack,
33+
getUserFacingStackLines,
34+
IgnoreList,
35+
type IgnoreRule,
36+
} from "../shared/finding-suppression";
37+
38+
export type { IgnoreRule } from "../shared/finding-suppression";
2939

3040
type PendingAccess = {
3141
canaryName: string;
42+
stack: string;
3243
invoked: boolean;
3344
};
3445

46+
/**
47+
* Configuration for the Code Injection bug detector.
48+
* Controls the reporting and suppression of dynamic code evaluation findings.
49+
*/
50+
export interface CodeInjectionConfig {
51+
/**
52+
* Disables Stage 1 (Access) reporting entirely.
53+
* The detector will no longer report when the canary is merely read.
54+
*/
55+
disableAccessReporting(): this;
56+
/**
57+
* Disables Stage 2 (Invocation) reporting entirely.
58+
* The detector will no longer report when the canary is actually executed.
59+
*/
60+
disableInvocationReporting(): this;
61+
/**
62+
* Suppresses Stage 1 (Access) findings that match the provided rule.
63+
* Use this to silence safe heuristic reads such as template lookups.
64+
*/
65+
ignoreAccess(rule: IgnoreRule): this;
66+
/**
67+
* Suppresses Stage 2 (Invocation) findings that match the provided rule.
68+
* Use this only for known-safe execution sinks in test environments.
69+
*/
70+
ignoreInvocation(rule: IgnoreRule): this;
71+
}
72+
73+
class CodeInjectionConfigImpl implements CodeInjectionConfig {
74+
private _reportAccess = true;
75+
private _reportInvocation = true;
76+
private readonly _ignoredAccessRules = new IgnoreList();
77+
private readonly _ignoredInvocationRules = new IgnoreList();
78+
79+
disableAccessReporting(): this {
80+
this._reportAccess = false;
81+
return this;
82+
}
83+
84+
disableInvocationReporting(): this {
85+
this._reportInvocation = false;
86+
return this;
87+
}
88+
89+
ignoreAccess(rule: IgnoreRule): this {
90+
this._ignoredAccessRules.add(rule);
91+
return this;
92+
}
93+
94+
ignoreInvocation(rule: IgnoreRule): this {
95+
this._ignoredInvocationRules.add(rule);
96+
return this;
97+
}
98+
99+
shouldReportAccess(stack: string): boolean {
100+
return this._reportAccess && !this._ignoredAccessRules.matches(stack);
101+
}
102+
103+
shouldReportInvocation(stack: string): boolean {
104+
return (
105+
this._reportInvocation && !this._ignoredInvocationRules.matches(stack)
106+
);
107+
}
108+
}
109+
110+
const config = new CodeInjectionConfigImpl();
111+
bugDetectorConfigurations.set("code-injection", config);
112+
113+
// The canary name is target-specific: Jest VM contexts and globalThis can have
114+
// different existing properties. Weak keys avoid retaining old VM contexts.
35115
const canaryCache = new WeakMap<object, string>();
36116

37117
// Canary access findings are delayed until the fuzz input finishes so a later
@@ -113,18 +193,35 @@ function ensureActiveCanary(): string {
113193
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
114194
return {
115195
get() {
116-
const pendingAccess = { canaryName, invoked: false };
117-
pendingAccesses.push(pendingAccess);
196+
const accessStack = captureStack();
197+
const pendingAccess = config.shouldReportAccess(accessStack)
198+
? {
199+
canaryName,
200+
stack: accessStack,
201+
invoked: false,
202+
}
203+
: undefined;
204+
if (pendingAccess) {
205+
pendingAccesses.push(pendingAccess);
206+
}
118207

119208
return function canaryCall() {
120-
pendingAccess.invoked = true;
121-
reportAndThrowFinding(
122-
buildFindingMessage(
123-
"Confirmed Code Injection (Canary Invoked)",
124-
`invoked canary: ${canaryName}`,
125-
),
126-
false,
127-
);
209+
const invocationStack = captureStack();
210+
if (config.shouldReportInvocation(invocationStack)) {
211+
if (pendingAccess) {
212+
pendingAccess.invoked = true;
213+
}
214+
reportAndThrowFinding(
215+
buildFindingMessage(
216+
"Confirmed Code Injection (Canary Invoked)",
217+
`invoked canary: ${canaryName}`,
218+
invocationStack,
219+
"ignoreInvocation",
220+
"If this execution sink is expected in your test environment, suppress it:",
221+
),
222+
false,
223+
);
224+
}
128225
};
129226
},
130227
enumerable: false,
@@ -141,12 +238,33 @@ function flushPendingAccesses(): void {
141238
buildFindingMessage(
142239
"Potential Code Injection (Canary Accessed)",
143240
`accessed canary: ${pendingAccess.canaryName}`,
241+
pendingAccess.stack,
242+
"ignoreAccess",
243+
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
144244
),
145245
false,
146246
);
147247
}
148248
}
149249

150-
function buildFindingMessage(title: string, action: string): string {
151-
return `${title} -- ${action}`;
250+
function buildFindingMessage(
251+
title: string,
252+
action: string,
253+
stack: string,
254+
suppressionMethod: "ignoreAccess" | "ignoreInvocation",
255+
hint: string,
256+
): string {
257+
const relevantStackLines = getUserFacingStackLines(stack);
258+
const message = [`${title} -- ${action}`];
259+
if (relevantStackLines.length > 0) {
260+
message.push(...relevantStackLines);
261+
}
262+
message.push(
263+
"",
264+
`[!] ${hint}`,
265+
" Example only: copy/paste it and adapt `stackPattern` to your needs.",
266+
"",
267+
buildGenericSuppressionSnippet("code-injection", suppressionMethod),
268+
);
269+
return message.join("\n");
152270
}

tests/bug-detectors/code-injection.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,93 @@ describe("CLI", () => {
9393
});
9494
}
9595

96+
it("prints a generic access suppression example", () => {
97+
const fuzzTest = fuzzTestBuilder
98+
.fuzzEntryPoint("heuristicReadAccessesCanary")
99+
.build();
100+
expect(() => {
101+
fuzzTest.execute();
102+
}).toThrow(FuzzingExitCode);
103+
expect(fuzzTest.stderr).toContain(
104+
'getBugDetectorConfiguration("code-injection")',
105+
);
106+
expect(fuzzTest.stderr).toContain(
107+
"Example only: copy/paste it and adapt `stackPattern` to your needs.",
108+
);
109+
expect(fuzzTest.stderr).toContain(
110+
"// Example only: adapt `stackPattern` to the shown stack above.",
111+
);
112+
expect(fuzzTest.stderr).toContain("?.ignoreAccess({");
113+
expect(fuzzTest.stderr).toContain('stackPattern: "test.js:10"');
114+
});
115+
116+
it("reports confirmed invocation when access reporting is disabled", () => {
117+
const fuzzTest = fuzzTestBuilder
118+
.fuzzEntryPoint("evalAccessesCanary")
119+
.customHooks(["disable-access.config.js"])
120+
.build();
121+
expect(() => {
122+
fuzzTest.execute();
123+
}).toThrow(FuzzingExitCode);
124+
expect(fuzzTest.stderr).toContain(invocationFindingMessage);
125+
expect(fuzzTest.stderr).toContain("?.ignoreInvocation({");
126+
});
127+
128+
it("falls back to potential access when invocation reporting is disabled", () => {
129+
const fuzzTest = fuzzTestBuilder
130+
.fuzzEntryPoint("evalAccessesCanary")
131+
.customHooks(["disable-invocation.config.js"])
132+
.build();
133+
expect(() => {
134+
fuzzTest.execute();
135+
}).toThrow(FuzzingExitCode);
136+
expect(fuzzTest.stderr).toContain(accessFindingMessage);
137+
expect(fuzzTest.stderr).not.toContain(invocationFindingMessage);
138+
});
139+
140+
it("suppresses heuristic access when a stack pattern matches", () => {
141+
const fuzzTest = fuzzTestBuilder
142+
.fuzzEntryPoint("heuristicReadAccessesCanary")
143+
.customHooks(["ignore-heuristic-access.config.js"])
144+
.build()
145+
.execute();
146+
expect(fuzzTest.stdout).toContain(okMessage);
147+
expect(fuzzTest.stderr).not.toContain(accessFindingMessage);
148+
});
149+
150+
it("reaches invocation reporting when access is ignored by stack pattern", () => {
151+
const fuzzTest = fuzzTestBuilder
152+
.fuzzEntryPoint("evalAccessesCanary")
153+
.customHooks(["ignore-access-by-stack.config.js"])
154+
.build();
155+
expect(() => {
156+
fuzzTest.execute();
157+
}).toThrow(FuzzingExitCode);
158+
expect(fuzzTest.stderr).toContain(invocationFindingMessage);
159+
});
160+
161+
it("falls back to potential access when invocation is ignored", () => {
162+
const fuzzTest = fuzzTestBuilder
163+
.fuzzEntryPoint("evalAccessesCanary")
164+
.customHooks(["ignore-invocation-only.config.js"])
165+
.build();
166+
expect(() => {
167+
fuzzTest.execute();
168+
}).toThrow(FuzzingExitCode);
169+
expect(fuzzTest.stderr).toContain(accessFindingMessage);
170+
expect(fuzzTest.stderr).not.toContain(invocationFindingMessage);
171+
});
172+
173+
it("suppresses invocation when the invocation rule matches", () => {
174+
const fuzzTest = fuzzTestBuilder
175+
.fuzzEntryPoint("evalAccessesCanary")
176+
.customHooks(["ignore-invocation.config.js"])
177+
.build();
178+
fuzzTest.execute();
179+
expect(fuzzTest.stderr).not.toContain(accessFindingMessage);
180+
expect(fuzzTest.stderr).not.toContain(invocationFindingMessage);
181+
});
182+
96183
it("Function.prototype should still exist", () => {
97184
const fuzzTest = fuzzTestBuilder
98185
.dryRun(false)
@@ -159,6 +246,33 @@ describe("Jest", () => {
159246
expect(fuzzTest.stderr).not.toContain(accessFindingMessage);
160247
});
161248

249+
it("reports confirmed invocation when access reporting is disabled", () => {
250+
const fuzzTest = fuzzTestBuilder
251+
.dryRun(false)
252+
.jestTestFile("tests.fuzz.js")
253+
.jestTestName("eval Accesses canary$")
254+
.customHooks(["disable-access.config.js"])
255+
.build();
256+
expect(() => {
257+
fuzzTest.execute();
258+
}).toThrow(JestRegressionExitCode);
259+
expect(fuzzTest.stderr).toContain(invocationFindingMessage);
260+
});
261+
262+
it("falls back to potential access when invocation reporting is disabled", () => {
263+
const fuzzTest = fuzzTestBuilder
264+
.dryRun(false)
265+
.jestTestFile("tests.fuzz.js")
266+
.jestTestName("eval Accesses canary$")
267+
.customHooks(["disable-invocation.config.js"])
268+
.build();
269+
expect(() => {
270+
fuzzTest.execute();
271+
}).toThrow(JestRegressionExitCode);
272+
expect(fuzzTest.stderr).toContain(accessFindingMessage);
273+
expect(fuzzTest.stderr).not.toContain(invocationFindingMessage);
274+
});
275+
162276
it("safe code stays quiet", () => {
163277
const fuzzTest = fuzzTestBuilder
164278
.dryRun(false)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
const {
18+
getBugDetectorConfiguration,
19+
} = require("../../../packages/bug-detectors");
20+
21+
getBugDetectorConfiguration("code-injection")?.disableAccessReporting();
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
const {
18+
getBugDetectorConfiguration,
19+
} = require("../../../packages/bug-detectors");
20+
21+
getBugDetectorConfiguration("code-injection")?.disableInvocationReporting();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
const {
18+
getBugDetectorConfiguration,
19+
} = require("../../../packages/bug-detectors");
20+
21+
getBugDetectorConfiguration("code-injection")?.ignoreAccess({
22+
stackPattern: "evalAccessesCanary",
23+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
const {
18+
getBugDetectorConfiguration,
19+
} = require("../../../packages/bug-detectors");
20+
21+
getBugDetectorConfiguration("code-injection")?.ignoreAccess({
22+
stackPattern: "heuristicReadAccessesCanary",
23+
});

0 commit comments

Comments
 (0)