Skip to content

Commit 6c5c670

Browse files
committed
feat(code-injection): replace RCE detector
Use an owned canary to distinguish heuristic reads from confirmed execution and avoid substring-only reports that confuse string literals with code execution.
1 parent 0e5ede9 commit 6c5c670

21 files changed

Lines changed: 1177 additions & 523 deletions

docs/bug-detectors.md

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,58 @@ using Jest in `.jazzerjsrc.json`:
9898
{ "disableBugDetectors": ["prototype-pollution"] }
9999
```
100100
101-
## Remote Code Execution
101+
## Code Injection
102102
103-
Hooks the `eval` and `Function` functions and reports a finding if the fuzzer
104-
was able to pass a special string to `eval` and to the function body of
105-
`Function`.
103+
Installs a canary on `globalThis` and hooks the `eval` and `Function` functions.
104+
The before-hooks guide the fuzzer toward injecting the active canary identifier
105+
into code strings. The detector reports two fatal stages by default:
106106
107-
_Disable with:_ `--disableBugDetectors=remote-code-execution` in CLI mode; or
108-
when using Jest in `.jazzerjsrc.json`:
107+
- `Potential Code Injection (Canary Accessed)` - some code resolved the canary.
108+
This high-recall heuristic catches cases where dynamically produced code reads
109+
or stores the canary before executing it later.
110+
- `Confirmed Code Injection (Canary Invoked)` - the callable canary returned by
111+
the getter was invoked.
112+
113+
The detector can be configured in the
114+
[custom hooks](./fuzz-settings.md#customhooks--arraystring) file.
115+
116+
- `disableAccessReporting` - disables the stage-1 access finding while keeping
117+
invocation reporting active.
118+
- `disableInvocationReporting` - disables the stage-2 invocation finding.
119+
- `ignoreAccess(rule)` - suppresses stage-1 findings matching the shown stack
120+
excerpt.
121+
- `ignoreInvocation(rule)` - suppresses stage-2 findings matching the shown
122+
stack excerpt.
123+
- `stackPattern` accepts either a string or a `RegExp` and is matched against
124+
the shown stack excerpt after removing the leading `Error` line and Jazzer.js
125+
frames. The remaining stack text is matched as shown, including path
126+
separators and column numbers.
127+
128+
The detector must be able to install a canary on at least one active global
129+
object. Locked-down environments that forbid this should disable the detector
130+
explicitly.
131+
132+
Here is an example configuration in the
133+
[custom hooks](./fuzz-settings.md#customhooks--arraystring) file:
134+
135+
```javascript
136+
const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");
137+
138+
getBugDetectorConfiguration("code-injection")
139+
?.ignoreAccess({
140+
stackPattern: "handlebars/runtime.js:87",
141+
})
142+
?.disableInvocationReporting();
143+
```
144+
145+
Findings print a generic example suppression snippet. Copy/paste it and adapt
146+
`stackPattern` to a stable substring or `RegExp` from the shown stack above.
147+
148+
_Disable with:_ `--disableBugDetectors=code-injection` in CLI mode; or when
149+
using Jest in `.jazzerjsrc.json`:
109150
110151
```json
111-
{ "disableBugDetectors": ["remote-code-execution"] }
152+
{ "disableBugDetectors": ["code-injection"] }
112153
```
113154
114155
## Server-Side Request Forgery (SSRF)
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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 type { Context } from "vm";
18+
19+
import {
20+
getJazzerJsGlobal,
21+
guideTowardsContainment,
22+
registerAfterEachCallback,
23+
reportAndThrowFinding,
24+
reportFinding,
25+
} from "@jazzer.js/core";
26+
import { registerBeforeHook } from "@jazzer.js/hooking";
27+
28+
import { bugDetectorConfigurations } from "../configuration";
29+
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";
39+
40+
type PendingAccess = {
41+
canaryName: string;
42+
stack: string;
43+
invoked: boolean;
44+
};
45+
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+
const canaryCache = new WeakMap<object, string>();
114+
const pendingAccesses: PendingAccess[] = [];
115+
116+
ensureActiveCanary();
117+
registerAfterEachCallback(flushPendingAccesses);
118+
119+
registerBeforeHook(
120+
"eval",
121+
"",
122+
false,
123+
function beforeEvalHook(
124+
_thisPtr: unknown,
125+
params: unknown[],
126+
hookId: number,
127+
) {
128+
const canaryName = ensureActiveCanary();
129+
130+
const code = params[0];
131+
if (typeof code === "string") {
132+
guideTowardsContainment(code, canaryName, hookId);
133+
}
134+
},
135+
);
136+
137+
registerBeforeHook(
138+
"Function",
139+
"",
140+
false,
141+
function beforeFunctionHook(
142+
_thisPtr: unknown,
143+
params: unknown[],
144+
hookId: number,
145+
) {
146+
const canaryName = ensureActiveCanary();
147+
if (params.length === 0) return;
148+
149+
const functionBody = params[params.length - 1];
150+
if (functionBody == null) return;
151+
152+
let functionBodySource: string;
153+
try {
154+
functionBodySource = String(functionBody);
155+
} catch {
156+
return;
157+
}
158+
// The hook has already performed Function's body coercion; reuse it so
159+
// user-provided toString methods are not invoked a second time.
160+
params[params.length - 1] = functionBodySource;
161+
162+
guideTowardsContainment(functionBodySource, canaryName, hookId);
163+
},
164+
);
165+
166+
function getVmContext(): Context | undefined {
167+
return getJazzerJsGlobal<Context>("vmContext");
168+
}
169+
170+
function ensureActiveCanary(): string {
171+
return ensureCanary(
172+
[
173+
{ label: "vmContext", object: getVmContext() },
174+
{ label: "globalThis", object: globalThis },
175+
],
176+
canaryCache,
177+
createCanaryDescriptor,
178+
);
179+
}
180+
181+
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
182+
return {
183+
get() {
184+
const accessStack = captureStack();
185+
const pendingAccess = config.shouldReportAccess(accessStack)
186+
? {
187+
canaryName,
188+
stack: accessStack,
189+
invoked: false,
190+
}
191+
: undefined;
192+
if (pendingAccess) {
193+
pendingAccesses.push(pendingAccess);
194+
}
195+
196+
return function canaryCall() {
197+
const invocationStack = captureStack();
198+
if (config.shouldReportInvocation(invocationStack)) {
199+
if (pendingAccess) {
200+
pendingAccess.invoked = true;
201+
}
202+
reportAndThrowFinding(
203+
buildFindingMessage(
204+
"Confirmed Code Injection (Canary Invoked)",
205+
`invoked canary: ${canaryName}`,
206+
invocationStack,
207+
"ignoreInvocation",
208+
"If this execution sink is expected in your test environment, suppress it:",
209+
),
210+
false,
211+
);
212+
}
213+
};
214+
},
215+
enumerable: false,
216+
configurable: false,
217+
};
218+
}
219+
220+
function flushPendingAccesses(): void {
221+
for (const pendingAccess of pendingAccesses.splice(0)) {
222+
if (pendingAccess.invoked) {
223+
continue;
224+
}
225+
reportFinding(
226+
buildFindingMessage(
227+
"Potential Code Injection (Canary Accessed)",
228+
`accessed canary: ${pendingAccess.canaryName}`,
229+
pendingAccess.stack,
230+
"ignoreAccess",
231+
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
232+
),
233+
false,
234+
);
235+
}
236+
}
237+
238+
function buildFindingMessage(
239+
title: string,
240+
action: string,
241+
stack: string,
242+
suppressionMethod: "ignoreAccess" | "ignoreInvocation",
243+
hint: string,
244+
): string {
245+
const relevantStackLines = getUserFacingStackLines(stack);
246+
const message = [`${title} -- ${action}`];
247+
if (relevantStackLines.length > 0) {
248+
message.push(...relevantStackLines);
249+
}
250+
message.push(
251+
"",
252+
`[!] ${hint}`,
253+
" Example only: copy/paste it and adapt `stackPattern` to your needs.",
254+
"",
255+
buildGenericSuppressionSnippet("code-injection", suppressionMethod),
256+
);
257+
return message.join("\n");
258+
}

packages/bug-detectors/internal/remote-code-execution.ts

Lines changed: 0 additions & 71 deletions
This file was deleted.

0 commit comments

Comments
 (0)