Skip to content

Commit a0570e2

Browse files
committed
feat(code-injection): replace RCE detector with canary
Use an owned canary to distinguish heuristic reads from confirmed execution instead of reporting string containment as remote code execution.
1 parent 69224f1 commit a0570e2

14 files changed

Lines changed: 733 additions & 516 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 { ensureCanary } from "../shared/code-injection-canary";
29+
30+
type PendingAccess = {
31+
canaryName: string;
32+
invoked: boolean;
33+
};
34+
35+
const canaryCache = new WeakMap<object, string>();
36+
37+
// Canary access findings are delayed until the fuzz input finishes so a later
38+
// canary invocation can supersede the heuristic access report.
39+
const pendingAccesses: PendingAccess[] = [];
40+
41+
ensureActiveCanary();
42+
registerAfterEachCallback(flushPendingAccesses);
43+
44+
registerBeforeHook(
45+
"eval",
46+
"",
47+
false,
48+
function beforeEvalHook(
49+
_thisPtr: unknown,
50+
params: unknown[],
51+
hookId: number,
52+
) {
53+
const canaryName = ensureActiveCanary();
54+
55+
const code = params[0];
56+
if (typeof code === "string") {
57+
guideTowardsContainment(code, canaryName, hookId);
58+
}
59+
},
60+
);
61+
62+
registerBeforeHook(
63+
"Function",
64+
"",
65+
false,
66+
function beforeFunctionHook(
67+
_thisPtr: unknown,
68+
params: unknown[],
69+
hookId: number,
70+
) {
71+
const canaryName = ensureActiveCanary();
72+
if (params.length === 0) return;
73+
74+
const functionBody = params[params.length - 1];
75+
if (functionBody == null) return;
76+
77+
let functionBodySource: string;
78+
if (typeof functionBody === "string") {
79+
functionBodySource = functionBody;
80+
} else {
81+
try {
82+
functionBodySource = String(functionBody);
83+
} catch {
84+
return;
85+
}
86+
// Function bodies are string-coercible. Coerce non-strings here so the
87+
// fuzzer can still learn object-provided code, then pass the coerced value
88+
// through to avoid invoking user toString methods a second time.
89+
params[params.length - 1] = functionBodySource;
90+
}
91+
guideTowardsContainment(functionBodySource, canaryName, hookId);
92+
},
93+
);
94+
95+
function getVmContext(): Context | undefined {
96+
return getJazzerJsGlobal<Context>("vmContext");
97+
}
98+
99+
function ensureActiveCanary(): string {
100+
return ensureCanary(
101+
[
102+
// Order matters: in Jest, eval/Function run inside Jest's VM context,
103+
// so generated code can only see a canary installed in that context.
104+
// CLI fuzzing has no VM context, so globalThis is the fallback.
105+
{ label: "vmContext", object: getVmContext() },
106+
{ label: "globalThis", object: globalThis },
107+
],
108+
canaryCache,
109+
createCanaryDescriptor,
110+
);
111+
}
112+
113+
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
114+
return {
115+
get() {
116+
const pendingAccess = { canaryName, invoked: false };
117+
pendingAccesses.push(pendingAccess);
118+
119+
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+
);
128+
};
129+
},
130+
enumerable: false,
131+
configurable: false,
132+
};
133+
}
134+
135+
function flushPendingAccesses(): void {
136+
for (const pendingAccess of pendingAccesses.splice(0)) {
137+
if (pendingAccess.invoked) {
138+
continue;
139+
}
140+
reportFinding(
141+
buildFindingMessage(
142+
"Potential Code Injection (Canary Accessed)",
143+
`accessed canary: ${pendingAccess.canaryName}`,
144+
),
145+
false,
146+
);
147+
}
148+
}
149+
150+
function buildFindingMessage(title: string, action: string): string {
151+
return `${title} -- ${action}`;
152+
}

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

Lines changed: 0 additions & 71 deletions
This file was deleted.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 { ensureCanary } from "./code-injection-canary";
18+
19+
const descriptorFactory = (canaryName: string): PropertyDescriptor => ({
20+
get: () => canaryName,
21+
configurable: false,
22+
});
23+
24+
describe("code injection canary", () => {
25+
test("reuses an already installed canary", () => {
26+
const target = {};
27+
const cache = new WeakMap<object, string>();
28+
29+
expect(ensureCanary(on(target), cache, descriptorFactory)).toBe("jaz_zer");
30+
expect(ensureCanary(on(target), cache, descriptorFactory)).toBe("jaz_zer");
31+
});
32+
33+
test("suffixes the canary name when the default one is already taken", () => {
34+
const target = { jaz_zer: true };
35+
const cache = new WeakMap<object, string>();
36+
37+
expect(ensureCanary(on(target), cache, descriptorFactory)).toBe(
38+
"jaz_zer_1",
39+
);
40+
});
41+
42+
test("continues when an earlier target rejects the canary", () => {
43+
const lockedTarget = Object.preventExtensions({});
44+
const openTarget = {};
45+
const cache = new WeakMap<object, string>();
46+
47+
expect(() => {
48+
ensureCanary(
49+
[
50+
{ label: "globalThis", object: lockedTarget },
51+
{ label: "vmContext", object: openTarget },
52+
],
53+
cache,
54+
descriptorFactory,
55+
);
56+
}).not.toThrow();
57+
expect(openTarget).toHaveProperty("jaz_zer", "jaz_zer");
58+
});
59+
60+
test("caches canary names per target", () => {
61+
const defaultTarget = {};
62+
const suffixedTarget = { jaz_zer: true };
63+
const cache = new WeakMap<object, string>();
64+
65+
expect(ensureCanary(on(defaultTarget), cache, descriptorFactory)).toBe(
66+
"jaz_zer",
67+
);
68+
expect(ensureCanary(on(suffixedTarget), cache, descriptorFactory)).toBe(
69+
"jaz_zer_1",
70+
);
71+
expect(ensureCanary(on(defaultTarget), cache, descriptorFactory)).toBe(
72+
"jaz_zer",
73+
);
74+
expect(ensureCanary(on(suffixedTarget), cache, descriptorFactory)).toBe(
75+
"jaz_zer_1",
76+
);
77+
});
78+
79+
test("fails loudly when no target accepts the canary", () => {
80+
const lockedTarget = Object.preventExtensions({});
81+
const cache = new WeakMap<object, string>();
82+
83+
expect(() => {
84+
ensureCanary(
85+
[{ label: "globalThis", object: lockedTarget }],
86+
cache,
87+
descriptorFactory,
88+
);
89+
}).toThrow(/could not install a canary on any available global object/i);
90+
expect(() => {
91+
ensureCanary(
92+
[{ label: "globalThis", object: lockedTarget }],
93+
cache,
94+
descriptorFactory,
95+
);
96+
}).toThrow(/--disableBugDetectors=code-injection/);
97+
});
98+
});
99+
100+
function on(object: object) {
101+
return [{ label: "target", object }];
102+
}

0 commit comments

Comments
 (0)