Skip to content

Commit cdf25e3

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 e49c083 commit cdf25e3

21 files changed

Lines changed: 1176 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: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
reportFinding,
24+
reportAndThrowFinding,
25+
} from "@jazzer.js/core";
26+
import { registerBeforeHook } from "@jazzer.js/hooking";
27+
28+
import { bugDetectorConfigurations } from "../configuration";
29+
30+
import {
31+
buildGenericSuppressionSnippet,
32+
captureStack,
33+
getUserFacingStackLines,
34+
IgnoreList,
35+
type IgnoreRule,
36+
} from "../shared/finding-suppression";
37+
import { ensureCanary } from "../shared/code-injection-canary";
38+
39+
export type { IgnoreRule } from "../shared/finding-suppression";
40+
41+
type PendingAccess = {
42+
canaryName: string;
43+
stack: string;
44+
invoked: boolean;
45+
};
46+
47+
/**
48+
* Configuration for the Code Injection bug detector.
49+
* Controls the reporting and suppression of dynamic code evaluation findings.
50+
*/
51+
export interface CodeInjectionConfig {
52+
/**
53+
* Disables Stage 1 (Access) reporting entirely.
54+
* The detector will no longer report when the canary is merely read.
55+
*/
56+
disableAccessReporting(): this;
57+
/**
58+
* Disables Stage 2 (Invocation) reporting entirely.
59+
* The detector will no longer report when the canary is actually executed.
60+
*/
61+
disableInvocationReporting(): this;
62+
/**
63+
* Suppresses Stage 1 (Access) findings that match the provided rule.
64+
* Use this to silence safe heuristic reads such as template lookups.
65+
*/
66+
ignoreAccess(rule: IgnoreRule): this;
67+
/**
68+
* Suppresses Stage 2 (Invocation) findings that match the provided rule.
69+
* Use this only for known-safe execution sinks in test environments.
70+
*/
71+
ignoreInvocation(rule: IgnoreRule): this;
72+
}
73+
74+
class CodeInjectionConfigImpl implements CodeInjectionConfig {
75+
private _reportAccess = true;
76+
private _reportInvocation = true;
77+
private readonly _ignoredAccessRules = new IgnoreList();
78+
private readonly _ignoredInvocationRules = new IgnoreList();
79+
80+
disableAccessReporting(): this {
81+
this._reportAccess = false;
82+
return this;
83+
}
84+
85+
disableInvocationReporting(): this {
86+
this._reportInvocation = false;
87+
return this;
88+
}
89+
90+
ignoreAccess(rule: IgnoreRule): this {
91+
this._ignoredAccessRules.add(rule);
92+
return this;
93+
}
94+
95+
ignoreInvocation(rule: IgnoreRule): this {
96+
this._ignoredInvocationRules.add(rule);
97+
return this;
98+
}
99+
100+
shouldReportAccess(stack: string): boolean {
101+
return this._reportAccess && !this._ignoredAccessRules.matches(stack);
102+
}
103+
104+
shouldReportInvocation(stack: string): boolean {
105+
return (
106+
this._reportInvocation && !this._ignoredInvocationRules.matches(stack)
107+
);
108+
}
109+
}
110+
111+
const config = new CodeInjectionConfigImpl();
112+
bugDetectorConfigurations.set("code-injection", config);
113+
114+
const canaryCache = new WeakMap<object, string>();
115+
const pendingAccesses: PendingAccess[] = [];
116+
117+
ensureActiveCanary();
118+
registerAfterEachCallback(flushPendingAccesses);
119+
120+
registerBeforeHook(
121+
"eval",
122+
"",
123+
false,
124+
function beforeEvalHook(
125+
_thisPtr: unknown,
126+
params: unknown[],
127+
hookId: number,
128+
) {
129+
const canaryName = ensureActiveCanary();
130+
131+
const code = params[0];
132+
if (typeof code === "string") {
133+
guideTowardsContainment(code, canaryName, hookId);
134+
}
135+
},
136+
);
137+
138+
registerBeforeHook(
139+
"Function",
140+
"",
141+
false,
142+
function beforeFunctionHook(
143+
_thisPtr: unknown,
144+
params: unknown[],
145+
hookId: number,
146+
) {
147+
const canaryName = ensureActiveCanary();
148+
if (params.length === 0) return;
149+
150+
const functionBody = params[params.length - 1];
151+
if (functionBody == null) return;
152+
153+
let functionBodySource: string;
154+
try {
155+
functionBodySource = String(functionBody);
156+
} catch {
157+
return;
158+
}
159+
// The hook has already performed Function's body coercion; reuse it so
160+
// user-provided toString methods are not invoked a second time.
161+
params[params.length - 1] = functionBodySource;
162+
163+
guideTowardsContainment(functionBodySource, canaryName, hookId);
164+
},
165+
);
166+
167+
function getVmContext(): Context | undefined {
168+
return getJazzerJsGlobal<Context>("vmContext");
169+
}
170+
171+
function ensureActiveCanary(): string {
172+
return ensureCanary(
173+
[
174+
{ label: "vmContext", object: getVmContext() },
175+
{ label: "globalThis", object: globalThis },
176+
],
177+
canaryCache,
178+
createCanaryDescriptor,
179+
);
180+
}
181+
182+
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
183+
return {
184+
get() {
185+
const accessStack = captureStack();
186+
const pendingAccess = config.shouldReportAccess(accessStack)
187+
? {
188+
canaryName,
189+
stack: accessStack,
190+
invoked: false,
191+
}
192+
: undefined;
193+
if (pendingAccess) {
194+
pendingAccesses.push(pendingAccess);
195+
}
196+
197+
return function canaryCall() {
198+
const invocationStack = captureStack();
199+
if (config.shouldReportInvocation(invocationStack)) {
200+
if (pendingAccess) {
201+
pendingAccess.invoked = true;
202+
}
203+
reportAndThrowFinding(
204+
buildFindingMessage(
205+
"Confirmed Code Injection (Canary Invoked)",
206+
`invoked canary: ${canaryName}`,
207+
invocationStack,
208+
"ignoreInvocation",
209+
"If this execution sink is expected in your test environment, suppress it:",
210+
),
211+
false,
212+
);
213+
}
214+
};
215+
},
216+
enumerable: false,
217+
configurable: false,
218+
};
219+
}
220+
221+
function flushPendingAccesses(): void {
222+
for (const pendingAccess of pendingAccesses.splice(0)) {
223+
if (pendingAccess.invoked) {
224+
continue;
225+
}
226+
reportFinding(
227+
buildFindingMessage(
228+
"Potential Code Injection (Canary Accessed)",
229+
`accessed canary: ${pendingAccess.canaryName}`,
230+
pendingAccess.stack,
231+
"ignoreAccess",
232+
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
233+
),
234+
false,
235+
);
236+
}
237+
}
238+
239+
function buildFindingMessage(
240+
title: string,
241+
action: string,
242+
stack: string,
243+
suppressionMethod: "ignoreAccess" | "ignoreInvocation",
244+
hint: string,
245+
): string {
246+
const relevantStackLines = getUserFacingStackLines(stack);
247+
const message = [`${title} -- ${action}`];
248+
if (relevantStackLines.length > 0) {
249+
message.push(...relevantStackLines);
250+
}
251+
message.push(
252+
"",
253+
`[!] ${hint}`,
254+
" Example only: copy/paste it and adapt `stackPattern` to your needs.",
255+
"",
256+
buildGenericSuppressionSnippet("code-injection", suppressionMethod),
257+
);
258+
return message.join("\n");
259+
}

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

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

0 commit comments

Comments
 (0)