Skip to content

Commit 1918f67

Browse files
committed
fix(code-injection): prefer invocation and fail loudly
1 parent 21b53f6 commit 1918f67

7 files changed

Lines changed: 366 additions & 44 deletions

File tree

docs/bug-detectors.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ The detector can be configured in the
148148
frames. The remaining stack text is matched as shown, including path
149149
separators and column numbers.
150150
151+
The detector must be able to install a canary on at least one active global
152+
object. Locked-down environments that forbid this should disable the detector
153+
explicitly.
154+
151155
Here is an example configuration in the
152156
[custom hooks](./fuzz-settings.md#customhooks--arraystring) file:
153157

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

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type { Context } from "vm";
1919
import {
2020
getJazzerJsGlobal,
2121
guideTowardsContainment,
22+
registerAfterEachCallback,
23+
reportFinding,
2224
reportAndThrowFinding,
2325
} from "@jazzer.js/core";
2426
import { registerBeforeHook } from "@jazzer.js/hooking";
@@ -32,11 +34,19 @@ import {
3234
IgnoreList,
3335
type IgnoreRule,
3436
} from "../shared/finding-suppression";
35-
36-
const BASE_CANARY_NAME = "jaz_zer";
37+
import {
38+
ensureCanaryInstalled,
39+
ensureCanaryInstalledOnAnyTarget,
40+
} from "../shared/code-injection-canary";
3741

3842
export type { IgnoreRule } from "../shared/finding-suppression";
3943

44+
type PendingAccess = {
45+
canaryName: string;
46+
stack: string;
47+
handledByInvocation: boolean;
48+
};
49+
4050
/**
4151
* Configuration for the Code Injection bug detector.
4252
* Controls the reporting and suppression of dynamic code evaluation findings.
@@ -105,8 +115,10 @@ const config = new CodeInjectionConfigImpl();
105115
bugDetectorConfigurations.set("code-injection", config);
106116

107117
const installedCanaries = new WeakMap<object, string>();
118+
const pendingAccesses: PendingAccess[] = [];
108119

109120
ensureKnownCanariesInstalled();
121+
registerAfterEachCallback(flushPendingAccesses);
110122

111123
registerBeforeHook(
112124
"eval",
@@ -153,11 +165,14 @@ registerBeforeHook(
153165
);
154166

155167
function ensureKnownCanariesInstalled(): void {
156-
ensureCanaryInstalled(globalThis);
157-
const vmContext = getVmContext();
158-
if (vmContext) {
159-
ensureCanaryInstalled(vmContext);
160-
}
168+
ensureCanaryInstalledOnAnyTarget(
169+
[
170+
{ label: "globalThis", object: globalThis },
171+
{ label: "vmContext", object: getVmContext() },
172+
],
173+
installedCanaries,
174+
createCanaryDescriptor,
175+
);
161176
}
162177

163178
function getVmContext(): Context | undefined {
@@ -167,48 +182,39 @@ function getVmContext(): Context | undefined {
167182
function getActiveCanaryName(): string {
168183
const vmContext = getVmContext();
169184
return vmContext
170-
? ensureCanaryInstalled(vmContext)
171-
: ensureCanaryInstalled(globalThis);
172-
}
173-
174-
function ensureCanaryInstalled(target: object): string {
175-
const knownCanaryName = installedCanaries.get(target);
176-
if (knownCanaryName) {
177-
return knownCanaryName;
178-
}
179-
180-
let canaryName = BASE_CANARY_NAME;
181-
let suffix = 0;
182-
while (Object.getOwnPropertyDescriptor(target, canaryName)) {
183-
suffix += 1;
184-
canaryName = `${BASE_CANARY_NAME}_${suffix}`;
185-
}
186-
187-
Object.defineProperty(target, canaryName, createCanaryDescriptor(canaryName));
188-
installedCanaries.set(target, canaryName);
189-
return canaryName;
185+
? ensureCanaryInstalled(
186+
vmContext,
187+
installedCanaries,
188+
createCanaryDescriptor,
189+
)
190+
: ensureCanaryInstalled(
191+
globalThis,
192+
installedCanaries,
193+
createCanaryDescriptor,
194+
);
190195
}
191196

192197
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
193198
return {
194199
get() {
195200
const accessStack = captureStack();
196-
if (config.shouldReportAccess(accessStack)) {
197-
reportAndThrowFinding(
198-
buildFindingMessage(
199-
"Potential Code Injection (Canary Accessed)",
200-
`accessed canary: ${canaryName}`,
201-
accessStack,
202-
"ignoreAccess",
203-
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
204-
),
205-
false,
206-
);
201+
const pendingAccess = config.shouldReportAccess(accessStack)
202+
? {
203+
canaryName,
204+
stack: accessStack,
205+
handledByInvocation: false,
206+
}
207+
: undefined;
208+
if (pendingAccess) {
209+
pendingAccesses.push(pendingAccess);
207210
}
208211

209212
return function canaryCall() {
210213
const invocationStack = captureStack();
211214
if (config.shouldReportInvocation(invocationStack)) {
215+
if (pendingAccess) {
216+
pendingAccess.handledByInvocation = true;
217+
}
212218
reportAndThrowFinding(
213219
buildFindingMessage(
214220
"Confirmed Code Injection (Canary Invoked)",
@@ -227,6 +233,24 @@ function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
227233
};
228234
}
229235

236+
function flushPendingAccesses(): void {
237+
for (const pendingAccess of pendingAccesses.splice(0)) {
238+
if (pendingAccess.handledByInvocation) {
239+
continue;
240+
}
241+
reportFinding(
242+
buildFindingMessage(
243+
"Potential Code Injection (Canary Accessed)",
244+
`accessed canary: ${pendingAccess.canaryName}`,
245+
pendingAccess.stack,
246+
"ignoreAccess",
247+
"If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:",
248+
),
249+
false,
250+
);
251+
}
252+
}
253+
230254
function buildFindingMessage(
231255
title: string,
232256
action: string,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
ensureCanaryInstalled,
19+
ensureCanaryInstalledOnAnyTarget,
20+
} from "./code-injection-canary";
21+
22+
const descriptorFactory = (canaryName: string): PropertyDescriptor => ({
23+
value: canaryName,
24+
configurable: false,
25+
});
26+
27+
describe("code injection canary", () => {
28+
test("reuses an already installed canary", () => {
29+
const target = {};
30+
const installedCanaries = new WeakMap<object, string>();
31+
32+
expect(
33+
ensureCanaryInstalled(target, installedCanaries, descriptorFactory),
34+
).toBe("jaz_zer");
35+
expect(
36+
ensureCanaryInstalled(target, installedCanaries, descriptorFactory),
37+
).toBe("jaz_zer");
38+
});
39+
40+
test("suffixes the canary name when the default one is already taken", () => {
41+
const target = { jaz_zer: true };
42+
const installedCanaries = new WeakMap<object, string>();
43+
44+
expect(
45+
ensureCanaryInstalled(target, installedCanaries, descriptorFactory),
46+
).toBe("jaz_zer_1");
47+
});
48+
49+
test("continues when an earlier target rejects the canary", () => {
50+
const lockedTarget = Object.preventExtensions({});
51+
const openTarget = {};
52+
const installedCanaries = new WeakMap<object, string>();
53+
54+
expect(() => {
55+
ensureCanaryInstalledOnAnyTarget(
56+
[
57+
{ label: "globalThis", object: lockedTarget },
58+
{ label: "vmContext", object: openTarget },
59+
],
60+
installedCanaries,
61+
descriptorFactory,
62+
);
63+
}).not.toThrow();
64+
expect(openTarget).toHaveProperty("jaz_zer", "jaz_zer");
65+
});
66+
67+
test("fails loudly when no target accepts the canary", () => {
68+
const lockedTarget = Object.preventExtensions({});
69+
const installedCanaries = new WeakMap<object, string>();
70+
71+
expect(() => {
72+
ensureCanaryInstalledOnAnyTarget(
73+
[{ label: "globalThis", object: lockedTarget }],
74+
installedCanaries,
75+
descriptorFactory,
76+
);
77+
}).toThrow(/could not install a canary on any available global object/i);
78+
expect(() => {
79+
ensureCanaryInstalledOnAnyTarget(
80+
[{ label: "globalThis", object: lockedTarget }],
81+
installedCanaries,
82+
descriptorFactory,
83+
);
84+
}).toThrow(/--disableBugDetectors=code-injection/);
85+
});
86+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 BASE_CANARY_NAME = "jaz_zer";
18+
19+
export type CanaryTarget = {
20+
label: string;
21+
object: object | undefined;
22+
};
23+
24+
type DescriptorFactory = (canaryName: string) => PropertyDescriptor;
25+
26+
export function ensureCanaryInstalled(
27+
target: object,
28+
installedCanaries: WeakMap<object, string>,
29+
createCanaryDescriptor: DescriptorFactory,
30+
): string {
31+
const knownCanaryName = installedCanaries.get(target);
32+
if (knownCanaryName) {
33+
return knownCanaryName;
34+
}
35+
36+
let canaryName = BASE_CANARY_NAME;
37+
let suffix = 0;
38+
while (Object.getOwnPropertyDescriptor(target, canaryName)) {
39+
suffix += 1;
40+
canaryName = `${BASE_CANARY_NAME}_${suffix}`;
41+
}
42+
43+
Object.defineProperty(target, canaryName, createCanaryDescriptor(canaryName));
44+
installedCanaries.set(target, canaryName);
45+
return canaryName;
46+
}
47+
48+
export function ensureCanaryInstalledOnAnyTarget(
49+
targets: CanaryTarget[],
50+
installedCanaries: WeakMap<object, string>,
51+
createCanaryDescriptor: DescriptorFactory,
52+
): void {
53+
const failures: string[] = [];
54+
let installedAnyCanary = false;
55+
56+
for (const target of targets) {
57+
if (!target.object) {
58+
continue;
59+
}
60+
61+
try {
62+
ensureCanaryInstalled(
63+
target.object,
64+
installedCanaries,
65+
createCanaryDescriptor,
66+
);
67+
installedAnyCanary = true;
68+
} catch (error) {
69+
failures.push(`${target.label}: ${describeError(error)}`);
70+
}
71+
}
72+
73+
if (!installedAnyCanary) {
74+
throw new Error(buildNoCanaryTargetMessage(failures));
75+
}
76+
}
77+
78+
function buildNoCanaryTargetMessage(failures: string[]): string {
79+
const lines = [
80+
"The Code Injection bug detector could not install a canary on any available global object.",
81+
"Disable it explicitly with --disableBugDetectors=code-injection or the equivalent Jest configuration if your environment intentionally locks down globals.",
82+
];
83+
84+
if (failures.length > 0) {
85+
lines.push("", "Installation failures:");
86+
lines.push(...failures.map((failure) => ` ${failure}`));
87+
}
88+
89+
return lines.join("\n");
90+
}
91+
92+
function describeError(error: unknown): string {
93+
if (error instanceof Error) {
94+
return `${error.name}: ${error.message}`;
95+
}
96+
if (typeof error === "string") {
97+
return error;
98+
}
99+
return String(error);
100+
}

0 commit comments

Comments
 (0)