From 97c020b4b9008600437bb20a48d825c3dcacfc50 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:39:37 +0200 Subject: [PATCH 01/10] test(jest): use isolated modules for ts-jest Keep Jest transpilation independent from project references so the build remains the single source of type-checking truth. --- jest.config.js | 1 + tsconfig.jest.json | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 tsconfig.jest.json diff --git a/jest.config.js b/jest.config.js index 92fd7ad4..653f9e19 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,6 +27,7 @@ module.exports = { "^.+\\.tsx?$": [ "ts-jest", { + tsconfig: "/tsconfig.jest.json", // ts-jest does not support composite project references. // It compiles workspace .ts sources in one flat program, // which breaks cross-package type resolution. Disabling diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 00000000..954d8e90 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": true + } +} From 533fc9582e0ce1614eb5eede73372eec9a0e2163 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:39:42 +0200 Subject: [PATCH 02/10] test(jest-runner): cover done callback misuse Reenable previously skipped test that calls multiple done-callbacks --- packages/jest-runner/fuzz.test.ts | 34 +++++++++++++------------------ 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/jest-runner/fuzz.test.ts b/packages/jest-runner/fuzz.test.ts index d0b88ae4..746304b5 100644 --- a/packages/jest-runner/fuzz.test.ts +++ b/packages/jest-runner/fuzz.test.ts @@ -182,27 +182,21 @@ describe("fuzz", () => { await rejects.toThrow(new RegExp(".*async or done.*")); }); - // This test is disabled as it prints an additional error message to the console, - // which breaks the CI pipeline. - it.skip("print error on multiple calls to done callback", async () => { - await new Promise((resolve, reject) => { - withMockTest(() => { - runInRegressionMode( - "fuzz", - asFindingAwareFuzzFn((_: Buffer, done: (e?: Error) => void) => { - done(); - done(); - // Use another promise to stop test from finishing too fast. - resolve("done called multiple times"); - }), - mockDefaultCorpus(), - new OptionsManager(OptionSource.DefaultJestOptions), - globalThis as Global.Global, - "standard", - ); - }).then(resolve, reject); + it("print error on multiple calls to done callback", async () => { + await withMockTest(() => { + runInRegressionMode( + "fuzz", + asFindingAwareFuzzFn((_: Buffer, done: (e?: Error) => void) => { + done(); + done(); + }), + mockDefaultCorpus(), + new OptionsManager(OptionSource.DefaultJestOptions), + globalThis as Global.Global, + "standard", + ); }); - expect(consoleErrorMock).toHaveBeenCalledTimes(1); + expect(consoleErrorMock).toHaveBeenCalledTimes(2); }); it("always call tests with empty input", async () => { From 327480da1c566ccfaef546e10b6c36ee8d39998b Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:39:46 +0200 Subject: [PATCH 03/10] test(jest-runner): isolate package root lookup Mock parent-directory scans without recursing through the spy so the missing-package test exercises the corpus lookup failure. --- packages/jest-runner/corpus.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/jest-runner/corpus.test.ts b/packages/jest-runner/corpus.test.ts index fcec3eae..f33fe917 100644 --- a/packages/jest-runner/corpus.test.ts +++ b/packages/jest-runner/corpus.test.ts @@ -95,7 +95,14 @@ describe("Corpus", () => { it("throw error if no package.json was found", () => { const fuzzTest = mockFuzzTest({ generatePackageJson: false }); - expect(() => new Corpus(fuzzTest, [])).toThrow(); + const readdirSync = jest.spyOn(fs, "readdirSync").mockReturnValue([]); + try { + expect(() => new Corpus(fuzzTest, [])).toThrow( + "Could not find package.json in any parent directory", + ); + } finally { + readdirSync.mockRestore(); + } }); }); }); From 9e39b2168bb45a3c4c01b3d46603466e4e210ca9 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:40:17 +0200 Subject: [PATCH 04/10] fix(core): surface callback afterEach findings Run afterEach callbacks before clearing callback findings so deferred detector reports are delivered through done(error). --- packages/core/core.test.ts | 57 ++++++++++++++++++++++++++++++++++++++ packages/core/core.ts | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/core/core.test.ts diff --git a/packages/core/core.test.ts b/packages/core/core.test.ts new file mode 100644 index 00000000..2e033220 --- /dev/null +++ b/packages/core/core.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { registerAfterEachCallback } from "./callback"; +import { asFindingAwareFuzzFn } from "./core"; +import { clearFirstFinding, Finding, reportFinding } from "./finding"; + +describe("asFindingAwareFuzzFn", () => { + let stderrWrite: jest.SpiedFunction; + + beforeEach(() => { + globalThis.JazzerJS = new Map(); + stderrWrite = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + }); + + afterEach(() => { + stderrWrite.mockRestore(); + clearFirstFinding(); + }); + + it("surfaces afterEach findings from async done callbacks", async () => { + registerAfterEachCallback(() => reportFinding("afterEach finding", false)); + const wrappedFn = asFindingAwareFuzzFn( + (_data, done: (err?: Error) => void) => { + setTimeout(() => done(), 0); + }, + false, + ); + + await new Promise((resolve, reject) => { + wrappedFn(Buffer.from(""), (error?: Error) => { + try { + expect(error).toBeInstanceOf(Finding); + expect(error?.message).toBe("afterEach finding"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); +}); diff --git a/packages/core/core.ts b/packages/core/core.ts index 4e7c0e8f..d375769b 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -452,11 +452,11 @@ export function asFindingAwareFuzzFn( callbacks.runBeforeEachCallbacks(); // Return result of fuzz target to enable sanity checks in C++ part. const result = originalFuzzFn(data, (err?) => { + callbacks.runAfterEachCallbacks(); const error = clearFirstFinding() ?? err; if (error) { printAndDump(error); } - callbacks.runAfterEachCallbacks(); done(error); }); // Check if any finding was reported by the invocation before the From 3a5408ee0342a61acff7ef220c132247ac91bc19 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:40:30 +0200 Subject: [PATCH 05/10] feat(bug-detectors): share stack suppressions Match suppressions against the stack text users see so detectors can share the same low-surprise ignore rules. --- .npmignore | 2 + .../shared/finding-suppression.test.ts | 96 +++++++++++++++ .../shared/finding-suppression.ts | 113 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 packages/bug-detectors/shared/finding-suppression.test.ts create mode 100644 packages/bug-detectors/shared/finding-suppression.ts diff --git a/.npmignore b/.npmignore index 771f7db1..a3d3d4ba 100644 --- a/.npmignore +++ b/.npmignore @@ -13,6 +13,8 @@ coverage node_modules tests shared +!**/dist/shared/ +!**/dist/shared/** # Exclude docs, those can be accessed online docs diff --git a/packages/bug-detectors/shared/finding-suppression.test.ts b/packages/bug-detectors/shared/finding-suppression.test.ts new file mode 100644 index 00000000..4dfe93af --- /dev/null +++ b/packages/bug-detectors/shared/finding-suppression.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + buildGenericSuppressionSnippet, + getUserFacingStack, + IgnoreList, + matchesIgnoreRules, +} from "./finding-suppression"; + +describe("finding suppression", () => { + const stack = [ + "Error", + " at renderTemplate (C:\\repo\\tests\\bug-detectors\\sample.test.js:10:42)", + " at handleRequest (/repo/src/server.js:20:7)", + " at internal (/repo/jazzer.js/packages/core/core.js:30:1)", + ].join("\n"); + + test("keeps the shown stack text unchanged for matching", () => { + expect(getUserFacingStack(stack)).toBe( + [ + " at renderTemplate (C:\\repo\\tests\\bug-detectors\\sample.test.js:10:42)", + " at handleRequest (/repo/src/server.js:20:7)", + ].join("\n"), + ); + }); + + test("matches string stack patterns against the shown stack", () => { + expect( + matchesIgnoreRules( + [ + { + stackPattern: + "C:\\repo\\tests\\bug-detectors\\sample.test.js:10:42", + }, + ], + stack, + ), + ).toBe(true); + expect( + matchesIgnoreRules( + [ + { + stackPattern: "C:/repo/tests/bug-detectors/sample.test.js:10:42", + }, + ], + stack, + ), + ).toBe(false); + }); + + test("matches regex stack patterns against the shown stack", () => { + expect( + matchesIgnoreRules( + [{ stackPattern: /handleRequest \(\/repo\/src\/server\.js:20:7\)/ }], + stack, + ), + ).toBe(true); + }); + + test("IgnoreList stores and matches suppression rules", () => { + const ignoreList = new IgnoreList(); + + ignoreList.add({ stackPattern: "sample.test.js:10:42" }); + + expect(ignoreList.matches(stack)).toBe(true); + }); + + test("prints generic example snippets with optional chaining", () => { + expect( + buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"), + ).toContain('getBugDetectorConfiguration("code-injection")'); + expect( + buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"), + ).toContain("?.ignoreInvocation({"); + expect( + buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"), + ).toContain('stackPattern: "test.js:10"'); + expect( + buildGenericSuppressionSnippet("code-injection", "ignoreInvocation"), + ).toContain("shown stack above"); + }); +}); diff --git a/packages/bug-detectors/shared/finding-suppression.ts b/packages/bug-detectors/shared/finding-suppression.ts new file mode 100644 index 00000000..abc09cc3 --- /dev/null +++ b/packages/bug-detectors/shared/finding-suppression.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const JAZZER_INTERNAL_STACK_MARKERS = [ + "/@jazzer.js/", + "/jazzer.js/packages/", + "/jazzer.js/core/", + "/jazzer.js-commercial/packages/", + "/jazzer.js-commercial/core/", + "../../packages/", +]; + +/** + * Defines a suppression rule for bug-detector findings. + */ +export interface IgnoreRule { + /** + * A string or regular expression matching the stack excerpt shown in the + * finding after removing the leading Error line and Jazzer.js frames. + * @example "src/templates.js:41" + * @example /renderTemplate.*handleRequest/s + */ + stackPattern?: string | RegExp; +} + +export class IgnoreList { + private readonly _rules: IgnoreRule[] = []; + + add(rule: IgnoreRule): void { + this._rules.push(rule); + } + + matches(stack: string): boolean { + return matchesIgnoreRules(this._rules, stack); + } +} + +export function matchesIgnoreRules( + rules: IgnoreRule[], + stack: string, +): boolean { + return rules.some((rule) => matchesIgnoreRule(rule, stack)); +} + +export function buildGenericSuppressionSnippet( + detectorName: string, + suppressionMethod: string, +): string { + return [ + 'const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");', + "", + "// Example only: adapt `stackPattern` to the shown stack above.", + `getBugDetectorConfiguration("${detectorName}")`, + ` ?.${suppressionMethod}({`, + ' stackPattern: "test.js:10",', + " });", + ].join("\n"); +} + +export function captureStack(): string { + return new Error().stack ?? ""; +} + +export function getUserFacingStack(stack: string): string { + return getUserFacingStackLines(stack).join("\n"); +} + +export function getUserFacingStackLines(stack: string): string[] { + return stack + .split("\n") + .slice(1) + .filter((line) => line !== "") + .filter((line) => !isJazzerInternalStackLine(line)); +} + +function matchesIgnoreRule(rule: IgnoreRule, stack: string): boolean { + return Boolean( + rule.stackPattern && + matchesStackPattern(rule.stackPattern, getUserFacingStack(stack)), + ); +} + +function isJazzerInternalStackLine(line: string): boolean { + const normalizedLine = line.replace(/\\/g, "/"); + return JAZZER_INTERNAL_STACK_MARKERS.some((marker) => + normalizedLine.includes(marker), + ); +} + +function matchesPattern(pattern: RegExp, value: string): boolean { + pattern.lastIndex = 0; + return pattern.test(value); +} + +function matchesStackPattern(pattern: string | RegExp, value: string): boolean { + if (typeof pattern === "string") { + return value.includes(pattern); + } + return matchesPattern(pattern, value); +} From 8068e3685f56e5cd63a81e74be2293a4064cbb01 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 17:47:44 +0200 Subject: [PATCH 06/10] 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. --- .../bug-detectors/internal/code-injection.ts | 142 ++++++++ .../internal/remote-code-execution.ts | 71 ---- .../shared/code-injection-canary.test.ts | 103 ++++++ .../shared/code-injection-canary.ts | 137 ++++++++ packages/core/cli.ts | 1 + tests/bug-detectors/code-injection.test.js | 180 +++++++++++ .../code-injection/context-a.fuzz.js | 23 ++ .../code-injection/context-b.fuzz.js | 23 ++ tests/bug-detectors/code-injection/fuzz.js | 107 +++++++ .../package.json | 4 +- .../code-injection/tests.fuzz.js | 45 +++ .../remote-code-execution.test.js | 303 ------------------ .../remote-code-execution/fuzz.js | 72 ----- .../remote-code-execution/tests.fuzz.js | 68 ---- 14 files changed, 763 insertions(+), 516 deletions(-) create mode 100644 packages/bug-detectors/internal/code-injection.ts delete mode 100644 packages/bug-detectors/internal/remote-code-execution.ts create mode 100644 packages/bug-detectors/shared/code-injection-canary.test.ts create mode 100644 packages/bug-detectors/shared/code-injection-canary.ts create mode 100644 tests/bug-detectors/code-injection.test.js create mode 100644 tests/bug-detectors/code-injection/context-a.fuzz.js create mode 100644 tests/bug-detectors/code-injection/context-b.fuzz.js create mode 100644 tests/bug-detectors/code-injection/fuzz.js rename tests/bug-detectors/{remote-code-execution => code-injection}/package.json (78%) create mode 100644 tests/bug-detectors/code-injection/tests.fuzz.js delete mode 100644 tests/bug-detectors/remote-code-execution.test.js delete mode 100644 tests/bug-detectors/remote-code-execution/fuzz.js delete mode 100644 tests/bug-detectors/remote-code-execution/tests.fuzz.js diff --git a/packages/bug-detectors/internal/code-injection.ts b/packages/bug-detectors/internal/code-injection.ts new file mode 100644 index 00000000..ec8ecc52 --- /dev/null +++ b/packages/bug-detectors/internal/code-injection.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Context } from "vm"; + +import { + getJazzerJsGlobal, + guideTowardsContainment, + registerAfterEachCallback, + reportAndThrowFinding, + reportFinding, +} from "@jazzer.js/core"; +import { registerBeforeHook } from "@jazzer.js/hooking"; + +import { ensureCanary } from "../shared/code-injection-canary"; + +type PendingAccess = { + canaryName: string; + invoked: boolean; +}; + +const canaryCache = new WeakMap(); +const pendingAccesses: PendingAccess[] = []; + +ensureActiveCanary(); +registerAfterEachCallback(flushPendingAccesses); + +registerBeforeHook( + "eval", + "", + false, + function beforeEvalHook( + _thisPtr: unknown, + params: unknown[], + hookId: number, + ) { + const canaryName = ensureActiveCanary(); + + const code = params[0]; + if (typeof code === "string") { + guideTowardsContainment(code, canaryName, hookId); + } + }, +); + +registerBeforeHook( + "Function", + "", + false, + function beforeFunctionHook( + _thisPtr: unknown, + params: unknown[], + hookId: number, + ) { + const canaryName = ensureActiveCanary(); + if (params.length === 0) return; + + const functionBody = params[params.length - 1]; + if (functionBody == null) return; + + let functionBodySource: string; + try { + functionBodySource = String(functionBody); + } catch { + return; + } + // The hook has already performed Function's body coercion; reuse it so + // user-provided toString methods are not invoked a second time. + params[params.length - 1] = functionBodySource; + + guideTowardsContainment(functionBodySource, canaryName, hookId); + }, +); + +function getVmContext(): Context | undefined { + return getJazzerJsGlobal("vmContext"); +} + +function ensureActiveCanary(): string { + return ensureCanary( + [ + { label: "vmContext", object: getVmContext() }, + { label: "globalThis", object: globalThis }, + ], + canaryCache, + createCanaryDescriptor, + ); +} + +function createCanaryDescriptor(canaryName: string): PropertyDescriptor { + return { + get() { + const pendingAccess = { canaryName, invoked: false }; + pendingAccesses.push(pendingAccess); + + return function canaryCall() { + pendingAccess.invoked = true; + reportAndThrowFinding( + buildFindingMessage( + "Confirmed Code Injection (Canary Invoked)", + `invoked canary: ${canaryName}`, + ), + false, + ); + }; + }, + enumerable: false, + configurable: false, + }; +} + +function flushPendingAccesses(): void { + for (const pendingAccess of pendingAccesses.splice(0)) { + if (pendingAccess.invoked) { + continue; + } + reportFinding( + buildFindingMessage( + "Potential Code Injection (Canary Accessed)", + `accessed canary: ${pendingAccess.canaryName}`, + ), + false, + ); + } +} + +function buildFindingMessage(title: string, action: string): string { + return `${title} -- ${action}`; +} diff --git a/packages/bug-detectors/internal/remote-code-execution.ts b/packages/bug-detectors/internal/remote-code-execution.ts deleted file mode 100644 index fbe724e3..00000000 --- a/packages/bug-detectors/internal/remote-code-execution.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2026 Code Intelligence GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - guideTowardsContainment, - reportAndThrowFinding, -} from "@jazzer.js/core"; -import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking"; - -const targetString = "jaz_zer"; - -registerBeforeHook( - "eval", - "", - false, - function beforeEvalHook(_thisPtr: unknown, params: string[], hookId: number) { - const code = params[0]; - // This check will prevent runtime TypeErrors should the user decide to call Function with - // non-string arguments. - // noinspection SuspiciousTypeOfGuard - if (typeof code === "string" && code.includes(targetString)) { - reportAndThrowFinding( - "Remote Code Execution\n" + ` using eval:\n '${code}'`, - ); - } - - // Since we do not hook eval using the hooking framework, we have to recompute the - // call site ID on every call to eval. This shouldn't be an issue, because eval is - // considered evil and should not be called too often, or even better -- not at all! - guideTowardsContainment(code, targetString, hookId); - }, -); - -registerBeforeHook( - "Function", - "", - false, - function beforeFunctionHook( - _thisPtr: unknown, - params: string[], - hookId: number, - ) { - if (params.length > 0) { - const functionBody = params[params.length - 1]; - - // noinspection SuspiciousTypeOfGuard - if (typeof functionBody === "string") { - if (functionBody.includes(targetString)) { - reportAndThrowFinding( - "Remote Code Execution\n" + - ` using Function:\n '${functionBody}'`, - ); - } - guideTowardsContainment(functionBody, targetString, hookId); - } - } - }, -); diff --git a/packages/bug-detectors/shared/code-injection-canary.test.ts b/packages/bug-detectors/shared/code-injection-canary.test.ts new file mode 100644 index 00000000..44628d60 --- /dev/null +++ b/packages/bug-detectors/shared/code-injection-canary.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ensureCanary } from "./code-injection-canary"; + +const descriptorFactory = (canaryName: string): PropertyDescriptor => ({ + get: () => canaryName, + configurable: false, +}); + +describe("code injection canary", () => { + test("reuses an already installed canary", () => { + const target = {}; + const cache = new WeakMap(); + + expect(ensureCanary(on(target), cache, descriptorFactory)).toBe("jaz_zer"); + expect(ensureCanary(on(target), cache, descriptorFactory)).toBe("jaz_zer"); + }); + + test("suffixes the canary name when the default one is already taken", () => { + const target = { jaz_zer: true }; + const cache = new WeakMap(); + + expect(ensureCanary(on(target), cache, descriptorFactory)).toBe( + "jaz_zer_1", + ); + }); + + test("continues when an earlier target rejects the canary", () => { + const lockedTarget = Object.preventExtensions({}); + const openTarget = {}; + const cache = new WeakMap(); + + expect(() => { + ensureCanary( + [ + { label: "globalThis", object: lockedTarget }, + { label: "vmContext", object: openTarget }, + ], + cache, + descriptorFactory, + ); + }).not.toThrow(); + expect(openTarget).toHaveProperty("jaz_zer", "jaz_zer"); + }); + + test("recognizes marked canaries without the original weak map", () => { + const target = {}; + + expect( + ensureCanary( + on(target), + new WeakMap(), + descriptorFactory, + ), + ).toBe("jaz_zer"); + expect( + ensureCanary( + on(target), + new WeakMap(), + descriptorFactory, + ), + ).toBe("jaz_zer"); + expect(target).not.toHaveProperty("jaz_zer_1"); + }); + + test("fails loudly when no target accepts the canary", () => { + const lockedTarget = Object.preventExtensions({}); + const cache = new WeakMap(); + + expect(() => { + ensureCanary( + [{ label: "globalThis", object: lockedTarget }], + cache, + descriptorFactory, + ); + }).toThrow(/could not install a canary on any available global object/i); + expect(() => { + ensureCanary( + [{ label: "globalThis", object: lockedTarget }], + cache, + descriptorFactory, + ); + }).toThrow(/--disableBugDetectors=code-injection/); + }); +}); + +function on(object: object) { + return [{ label: "target", object }]; +} diff --git a/packages/bug-detectors/shared/code-injection-canary.ts b/packages/bug-detectors/shared/code-injection-canary.ts new file mode 100644 index 00000000..f69267a6 --- /dev/null +++ b/packages/bug-detectors/shared/code-injection-canary.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const BASE_CANARY_NAME = "jaz_zer"; + +export type CanaryTarget = { + label: string; + object: object | undefined; +}; + +type CanaryGetter = () => unknown; +type DescriptorFactory = (canaryName: string) => PropertyDescriptor; +type CanaryCache = WeakMap; + +const canaryGetters = new WeakSet(); + +export function ensureCanary( + targets: CanaryTarget[], + cache: CanaryCache, + createDescriptor: DescriptorFactory, +): string { + const failures: string[] = []; + + for (const target of targets) { + if (!target.object) { + continue; + } + + try { + return ensureTargetCanary(target.object, cache, createDescriptor); + } catch (error) { + failures.push(`${target.label}: ${describeError(error)}`); + } + } + + throw new Error(buildNoCanaryTargetMessage(failures)); +} + +function ensureTargetCanary( + target: object, + cache: CanaryCache, + createDescriptor: DescriptorFactory, +): string { + const knownCanaryName = findInstalledCanaryName(target, cache); + if (knownCanaryName) { + return knownCanaryName; + } + + let canaryName = BASE_CANARY_NAME; + let suffix = 0; + while (Object.getOwnPropertyDescriptor(target, canaryName)) { + suffix += 1; + canaryName = `${BASE_CANARY_NAME}_${suffix}`; + } + + Object.defineProperty( + target, + canaryName, + markCanaryDescriptor(createDescriptor(canaryName)), + ); + cache.set(target, canaryName); + return canaryName; +} + +function findInstalledCanaryName( + target: object, + cache: CanaryCache, +): string | undefined { + const knownCanaryName = cache.get(target); + if (knownCanaryName && isMarkedCanary(target, knownCanaryName)) { + return knownCanaryName; + } + + let canaryName = BASE_CANARY_NAME; + let suffix = 0; + while (Object.getOwnPropertyDescriptor(target, canaryName)) { + if (isMarkedCanary(target, canaryName)) { + cache.set(target, canaryName); + return canaryName; + } + suffix += 1; + canaryName = `${BASE_CANARY_NAME}_${suffix}`; + } + return undefined; +} + +function markCanaryDescriptor( + descriptor: PropertyDescriptor, +): PropertyDescriptor { + if (typeof descriptor.get !== "function") { + throw new Error("Code injection canaries must use getter descriptors."); + } + canaryGetters.add(descriptor.get); + return descriptor; +} + +function isMarkedCanary(target: object, canaryName: string): boolean { + const getter = Object.getOwnPropertyDescriptor(target, canaryName)?.get; + return typeof getter === "function" && canaryGetters.has(getter); +} + +function buildNoCanaryTargetMessage(failures: string[]): string { + const lines = [ + "The Code Injection bug detector could not install a canary on any available global object.", + "Disable it explicitly with --disableBugDetectors=code-injection or the equivalent Jest configuration if your environment intentionally locks down globals.", + ]; + + if (failures.length > 0) { + lines.push("", "Installation failures:"); + lines.push(...failures.map((failure) => ` ${failure}`)); + } + + return lines.join("\n"); +} + +function describeError(error: unknown): string { + if (error instanceof Error) { + return `${error.name}: ${error.message}`; + } + if (typeof error === "string") { + return error; + } + return String(error); +} diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 13ccdf5b..3ee9a879 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -160,6 +160,7 @@ yargs(process.argv.slice(2)) "bug detectors are enabled. To disable all, use the '.*' pattern." + "Following bug detectors are available: " + " command-injection\n" + + " code-injection\n" + " path-traversal\n" + " prototype-pollution\n", group: "Fuzzer:", diff --git a/tests/bug-detectors/code-injection.test.js b/tests/bug-detectors/code-injection.test.js new file mode 100644 index 00000000..15990cfe --- /dev/null +++ b/tests/bug-detectors/code-injection.test.js @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const path = require("path"); + +const { + FuzzTestBuilder, + FuzzingExitCode, + JestRegressionExitCode, +} = require("../helpers.js"); + +const bugDetectorDirectory = path.join(__dirname, "code-injection"); + +const accessFindingMessage = "Potential Code Injection (Canary Accessed)"; +const invocationFindingMessage = "Confirmed Code Injection (Canary Invoked)"; +const okMessage = "can be called just fine"; +let fuzzTestBuilder; + +beforeEach(() => { + fuzzTestBuilder = new FuzzTestBuilder() + .runs(0) + .dir(bugDetectorDirectory) + .sync(true); +}); + +describe("CLI", () => { + const confirmedFindingCases = [ + "evalAccessesCanary", + "evalIndirectAccessesCanary", + "evalCommaOperatorAccessesCanary", + "evalOptionalChainingAccessesCanary", + "functionAccessesCanary", + "functionNewAccessesCanary", + "functionWithArgAccessesCanary", + "functionStringCoercibleAccessesCanary", + ]; + + const accessFindingCases = ["heuristicReadAccessesCanary"]; + + for (const entryPoint of confirmedFindingCases) { + it(`${entryPoint} reports confirmed code injection`, () => { + const fuzzTest = fuzzTestBuilder.fuzzEntryPoint(entryPoint).build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + expect(fuzzTest.stderr).not.toContain(accessFindingMessage); + }); + } + + for (const entryPoint of accessFindingCases) { + it(`${entryPoint} reports potential code injection`, () => { + const fuzzTest = fuzzTestBuilder.fuzzEntryPoint(entryPoint).build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(accessFindingMessage); + }); + } + + const noFindingCases = [ + "evalSafeCode", + "evalTargetInStringLiteral", + "functionSafeCode", + "functionSafeCodeNew", + "functionTargetInArgName", + "functionTargetInStringLiteral", + "functionStringCoercibleSafe", + "functionCoercesOnce", + ]; + + for (const entryPoint of noFindingCases) { + it(`${entryPoint} stays quiet`, () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint(entryPoint) + .build() + .execute(); + expect(fuzzTest.stdout).toContain(okMessage); + }); + } + + it("Function.prototype should still exist", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .fuzzEntryPoint("functionPrototypeExists") + .build(); + fuzzTest.execute(); + }); +}); + +describe("Jest", () => { + it("keeps the canary stable across sequential Jest files", () => { + const proc = spawnSync( + "npx", + [ + "jest", + "--runInBand", + "--no-colors", + "--runTestsByPath", + "context-a.fuzz.js", + "context-b.fuzz.js", + ], + { + cwd: bugDetectorDirectory, + env: { ...process.env }, + shell: true, + stdio: "pipe", + windowsHide: true, + }, + ); + + const output = proc.stdout.toString() + proc.stderr.toString(); + expect(proc.status?.toString()).toBe(JestRegressionExitCode); + expect(output).toContain("context-a.fuzz.js"); + expect(output).toContain("context-b.fuzz.js"); + expect(output).not.toContain("invoked canary: jaz_zer_1"); + expect( + (output.match(/invoked canary: jaz_zer/g) ?? []).length, + ).toBeGreaterThanOrEqual(2); + }); + + it("reports confirmed invocation", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("eval Accesses canary$") + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + expect(fuzzTest.stderr).not.toContain(accessFindingMessage); + }); + + it("reports confirmed invocation for Function", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("Function Accesses canary$") + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + expect(fuzzTest.stderr).not.toContain(accessFindingMessage); + }); + + it("safe code stays quiet", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("eval Safe code - no error$") + .build() + .execute(); + expect(fuzzTest.stdout).toContain(okMessage); + }); + + it("Function.prototype should still exist", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("Function Function.prototype still exists$") + .build(); + fuzzTest.execute(); + }); +}); diff --git a/tests/bug-detectors/code-injection/context-a.fuzz.js b/tests/bug-detectors/code-injection/context-a.fuzz.js new file mode 100644 index 00000000..f0ab0b0b --- /dev/null +++ b/tests/bug-detectors/code-injection/context-a.fuzz.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const tests = require("./fuzz"); + +describe("context a", () => { + it.fuzz("Accesses canary", (data) => { + tests.evalAccessesCanary(data); + }); +}); diff --git a/tests/bug-detectors/code-injection/context-b.fuzz.js b/tests/bug-detectors/code-injection/context-b.fuzz.js new file mode 100644 index 00000000..687ea2bd --- /dev/null +++ b/tests/bug-detectors/code-injection/context-b.fuzz.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const tests = require("./fuzz"); + +describe("context b", () => { + it.fuzz("Accesses canary", (data) => { + tests.evalAccessesCanary(data); + }); +}); diff --git a/tests/bug-detectors/code-injection/fuzz.js b/tests/bug-detectors/code-injection/fuzz.js new file mode 100644 index 00000000..d4771d6b --- /dev/null +++ b/tests/bug-detectors/code-injection/fuzz.js @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// --- eval / Function: should report potential access by default --- + +module.exports.evalAccessesCanary = function (_data) { + eval("jaz_zer()"); +}; + +module.exports.evalIndirectAccessesCanary = function (_data) { + const indirectEval = eval; + indirectEval("jaz_zer()"); +}; + +module.exports.evalCommaOperatorAccessesCanary = function (_data) { + (0, eval)("jaz_zer()"); +}; + +module.exports.evalOptionalChainingAccessesCanary = function (_data) { + eval?.("jaz_zer()"); +}; + +module.exports.heuristicReadAccessesCanary = function (_data) { + const propertyName = "jaz_zer"; + void globalThis[propertyName]; + console.log("can be called just fine"); +}; + +module.exports.functionAccessesCanary = function (_data) { + Function("jaz_zer()")(); +}; + +module.exports.functionNewAccessesCanary = function (_data) { + new Function("jaz_zer()")(); +}; + +module.exports.functionWithArgAccessesCanary = function (_data) { + new Function("value", "jaz_zer()")("_"); +}; + +module.exports.functionStringCoercibleAccessesCanary = function (_data) { + const body = { toString: () => "jaz_zer()" }; + Function(body)(); +}; + +module.exports.functionCoercesOnce = function (_data) { + let toStringCalls = 0; + const body = { + toString: () => { + toStringCalls += 1; + return toStringCalls === 1 + ? "console.log('can be called just fine')" + : "throw new Error('Function body was coerced twice')"; + }, + }; + Function(body)(); +}; + +// --- eval / Function: should not trigger --- + +module.exports.evalSafeCode = function (_data) { + eval("const a = 10; const b = 20; console.log('can be called just fine')"); +}; + +module.exports.evalTargetInStringLiteral = function (_data) { + eval("const x = 'jaz_zer'; console.log('can be called just fine')"); +}; + +module.exports.functionSafeCode = function (_data) { + Function("console.log('can be called just fine')")(); +}; + +module.exports.functionSafeCodeNew = function (_data) { + new Function("console.log('can be called just fine')")(); +}; + +module.exports.functionTargetInArgName = function (_data) { + new Function("jaz_zer", "console.log('can be called just fine')")("_"); +}; + +module.exports.functionTargetInStringLiteral = function (_data) { + new Function("const x = 'jaz_zer'; console.log('can be called just fine')")(); +}; + +module.exports.functionStringCoercibleSafe = function (_data) { + const body = { + toString: () => "console.log('can be called just fine')", + }; + Function(body)(); +}; + +module.exports.functionPrototypeExists = function (_data) { + console.log(Function.prototype.call.bind); +}; diff --git a/tests/bug-detectors/remote-code-execution/package.json b/tests/bug-detectors/code-injection/package.json similarity index 78% rename from tests/bug-detectors/remote-code-execution/package.json rename to tests/bug-detectors/code-injection/package.json index fcb22af7..3deb157a 100644 --- a/tests/bug-detectors/remote-code-execution/package.json +++ b/tests/bug-detectors/code-injection/package.json @@ -1,7 +1,7 @@ { - "name": "jazzerjs-remote-code-execution-tests", + "name": "jazzerjs-code-injection-tests", "version": "1.0.0", - "description": "Tests for the Remote Code Execution bug detector", + "description": "Tests for the Code Injection bug detector", "scripts": { "test": "jest", "fuzz": "JAZZER_FUZZ=1 jest" diff --git a/tests/bug-detectors/code-injection/tests.fuzz.js b/tests/bug-detectors/code-injection/tests.fuzz.js new file mode 100644 index 00000000..fc18a4e5 --- /dev/null +++ b/tests/bug-detectors/code-injection/tests.fuzz.js @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const tests = require("./fuzz"); + +describe("eval", () => { + it.fuzz("Accesses canary", (data) => { + tests.evalAccessesCanary(data); + }); + + it.fuzz("Safe code - no error", (data) => { + tests.evalSafeCode(data); + }); + + it.fuzz("Target in string literal - no error", (data) => { + tests.evalTargetInStringLiteral(data); + }); +}); + +describe("Function", () => { + it.fuzz("Accesses canary", (data) => { + tests.functionAccessesCanary(data); + }); + + it.fuzz("Safe code - no error", (data) => { + tests.functionSafeCode(data); + }); + + it.fuzz("Function.prototype still exists", (data) => { + tests.functionPrototypeExists(data); + }); +}); diff --git a/tests/bug-detectors/remote-code-execution.test.js b/tests/bug-detectors/remote-code-execution.test.js deleted file mode 100644 index 9d251eb3..00000000 --- a/tests/bug-detectors/remote-code-execution.test.js +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright 2026 Code Intelligence GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const path = require("path"); - -const { - FuzzTestBuilder, - FuzzingExitCode, - JestRegressionExitCode, -} = require("../helpers.js"); - -const bugDetectorDirectory = path.join(__dirname, "remote-code-execution"); - -const findingMessage = "Remote Code Execution"; -const okMessage = "can be called just fine"; -let fuzzTestBuilder; - -beforeEach(() => { - fuzzTestBuilder = new FuzzTestBuilder() - .runs(0) - .dir(bugDetectorDirectory) - .sync(true); -}); - -describe("CLI", () => { - describe("eval ()", () => { - it("Invocation without error", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("invocationWithoutError") - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("Direct invocation", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("directInvocation") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("indirectInvocation") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation using comma operator", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("indirectInvocationUsingCommaOperator") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation through optional chaining", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("indirectInvocationThroughOptionalChaining") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - }); - - describe("Function constructor", () => { - it("Invocation without error, without explicit constructor", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionNoErrorNoConstructor") - .sync(true) - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("Invocation without error", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionNoErrorWithConstructor") - .sync(true) - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("Direct invocation", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionError") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Direct invocation using new", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionErrorNew") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Target string in variable name - no error", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionWithArgNoError") - .sync(true) - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("With error - target string in last arg", () => { - const fuzzTest = fuzzTestBuilder - .fuzzEntryPoint("functionWithArgError") - .sync(true) - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(FuzzingExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Function.prototype should still exist", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .fuzzEntryPoint("functionPrototypeExists") - .sync(true) - .build(); - fuzzTest.execute(); - }); - }); -}); - -describe("Jest", () => { - describe("eval", () => { - it("Direct invocation", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("eval Direct invocation$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("eval Indirect invocation$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation using comma operator", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("eval Indirect invocation using comma operator$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Indirect invocation using optional chaining", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .verbose(true) - .jestTestFile("tests.fuzz.js") - .jestTestName("eval Indirect invocation through optional chaining$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("No error with absence of the target string", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("eval No error$") - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - }); - - describe("Function constructor", () => { - it("No error", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function No error$") - .build(); - fuzzTest.execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("No error with constructor", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function No error with constructor$") - .build(); - fuzzTest.execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("With error", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function With error$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("With error with constructor", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function With error with constructor$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Variable name containing target string should not throw", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function Target string in variable name - no error$") - .build() - .execute(); - expect(fuzzTest.stdout).toContain(okMessage); - }); - - it("With variable, body contains target string - should throw", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function With error - target string in last arg$") - .build(); - expect(() => { - fuzzTest.execute(); - }).toThrow(JestRegressionExitCode); - expect(fuzzTest.stderr).toContain(findingMessage); - }); - - it("Function.prototype should still exist", () => { - const fuzzTest = fuzzTestBuilder - .dryRun(false) - .jestTestFile("tests.fuzz.js") - .jestTestName("Function.prototype still exists$") - .build(); - fuzzTest.execute(); - }); - }); -}); diff --git a/tests/bug-detectors/remote-code-execution/fuzz.js b/tests/bug-detectors/remote-code-execution/fuzz.js deleted file mode 100644 index daab6b95..00000000 --- a/tests/bug-detectors/remote-code-execution/fuzz.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2026 Code Intelligence GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const printOkMessage = "console.log('can be called just fine')"; - -// eval -module.exports.invocationWithoutError = function (data) { - eval("const a = 10; const b = 20;" + printOkMessage); -}; - -module.exports.directInvocation = function (data) { - eval("const jaz_zer = 10;"); -}; - -module.exports.indirectInvocation = function (data) { - const a = eval; - a("const jaz_zer = 10;"); -}; - -module.exports.indirectInvocationUsingCommaOperator = function (data) { - (0, eval)("const jaz_zer = 10;"); -}; - -module.exports.indirectInvocationThroughOptionalChaining = function (data) { - eval?.("const jaz_zer = 10;"); -}; - -// Function -module.exports.functionNoErrorNoConstructor = function (data) { - Function("const a = 10; const b = 20;" + printOkMessage)(); -}; - -module.exports.functionNoErrorWithConstructor = function (data) { - const fn = new Function("const a = 10; const b = 20;" + printOkMessage); - fn(); -}; - -module.exports.functionError = function (data) { - Function("const jaz_zer = 10;"); -}; - -module.exports.functionErrorNew = function (data) { - new Function("const jaz_zer = 10;")(); -}; - -module.exports.functionWithArgNoError = function (data) { - new Function( - "jaz_zer", - "const foo = 10; console.log('Function can be called just fine')", - )("_"); -}; - -module.exports.functionWithArgError = function (data) { - new Function("foo", "const jaz_zer = 10;")("_"); -}; - -module.exports.functionPrototypeExists = function (data) { - console.log(Function.prototype.call.bind); -}; diff --git a/tests/bug-detectors/remote-code-execution/tests.fuzz.js b/tests/bug-detectors/remote-code-execution/tests.fuzz.js deleted file mode 100644 index a7a7a3a8..00000000 --- a/tests/bug-detectors/remote-code-execution/tests.fuzz.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2026 Code Intelligence GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const tests = require("./fuzz"); - -describe("eval", () => { - it.fuzz("No error", (data) => { - tests.invocationWithoutError(data); - }); - - it.fuzz("Direct invocation", (data) => { - tests.directInvocation(data); - }); - - it.fuzz("Indirect invocation", (data) => { - tests.indirectInvocation(data); - }); - - it.fuzz("Indirect invocation using comma operator", (data) => { - tests.indirectInvocationUsingCommaOperator(data); - }); - - it.fuzz("Indirect invocation through optional chaining", (data) => { - tests.indirectInvocationThroughOptionalChaining(data); - }); -}); - -describe("Function", () => { - it.fuzz("No error", (data) => { - tests.functionNoErrorNoConstructor(); - }); - it.fuzz("No error with constructor", (data) => { - tests.functionNoErrorWithConstructor(data); - }); - - it.fuzz("With error", (data) => { - tests.functionError(data); - }); - - it.fuzz("With error with constructor", (data) => { - tests.functionErrorNew(data); - }); - - it.fuzz("Target string in variable name - no error", (data) => { - tests.functionWithArgNoError(data); - }); - - it.fuzz("With error - target string in last arg", (data) => { - tests.functionWithArgError(data); - }); - - it.fuzz("Function.prototype still exists", (data) => { - tests.functionPrototypeExists(data); - }); -}); From 5590c9c1e0335462c0fdb045fafde2927e148682 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 17:48:50 +0200 Subject: [PATCH 07/10] feat(code-injection): add stack suppressions Let users disable or ignore expected canary access and invocation findings using the shown-stack suppression rules. --- .../bug-detectors/internal/code-injection.ts | 140 ++++++++++++++++-- tests/bug-detectors/code-injection.test.js | 114 ++++++++++++++ .../code-injection/disable-access.config.js | 21 +++ .../disable-invocation.config.js | 21 +++ .../ignore-access-by-stack.config.js | 23 +++ .../ignore-heuristic-access.config.js | 23 +++ .../ignore-invocation-only.config.js | 23 +++ .../ignore-invocation.config.js | 25 ++++ 8 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 tests/bug-detectors/code-injection/disable-access.config.js create mode 100644 tests/bug-detectors/code-injection/disable-invocation.config.js create mode 100644 tests/bug-detectors/code-injection/ignore-access-by-stack.config.js create mode 100644 tests/bug-detectors/code-injection/ignore-heuristic-access.config.js create mode 100644 tests/bug-detectors/code-injection/ignore-invocation-only.config.js create mode 100644 tests/bug-detectors/code-injection/ignore-invocation.config.js diff --git a/packages/bug-detectors/internal/code-injection.ts b/packages/bug-detectors/internal/code-injection.ts index ec8ecc52..f2b50739 100644 --- a/packages/bug-detectors/internal/code-injection.ts +++ b/packages/bug-detectors/internal/code-injection.ts @@ -25,13 +25,91 @@ import { } from "@jazzer.js/core"; import { registerBeforeHook } from "@jazzer.js/hooking"; +import { bugDetectorConfigurations } from "../configuration"; import { ensureCanary } from "../shared/code-injection-canary"; +import { + buildGenericSuppressionSnippet, + captureStack, + getUserFacingStackLines, + IgnoreList, + type IgnoreRule, +} from "../shared/finding-suppression"; + +export type { IgnoreRule } from "../shared/finding-suppression"; type PendingAccess = { canaryName: string; + stack: string; invoked: boolean; }; +/** + * Configuration for the Code Injection bug detector. + * Controls the reporting and suppression of dynamic code evaluation findings. + */ +export interface CodeInjectionConfig { + /** + * Disables Stage 1 (Access) reporting entirely. + * The detector will no longer report when the canary is merely read. + */ + disableAccessReporting(): this; + /** + * Disables Stage 2 (Invocation) reporting entirely. + * The detector will no longer report when the canary is actually executed. + */ + disableInvocationReporting(): this; + /** + * Suppresses Stage 1 (Access) findings that match the provided rule. + * Use this to silence safe heuristic reads such as template lookups. + */ + ignoreAccess(rule: IgnoreRule): this; + /** + * Suppresses Stage 2 (Invocation) findings that match the provided rule. + * Use this only for known-safe execution sinks in test environments. + */ + ignoreInvocation(rule: IgnoreRule): this; +} + +class CodeInjectionConfigImpl implements CodeInjectionConfig { + private _reportAccess = true; + private _reportInvocation = true; + private readonly _ignoredAccessRules = new IgnoreList(); + private readonly _ignoredInvocationRules = new IgnoreList(); + + disableAccessReporting(): this { + this._reportAccess = false; + return this; + } + + disableInvocationReporting(): this { + this._reportInvocation = false; + return this; + } + + ignoreAccess(rule: IgnoreRule): this { + this._ignoredAccessRules.add(rule); + return this; + } + + ignoreInvocation(rule: IgnoreRule): this { + this._ignoredInvocationRules.add(rule); + return this; + } + + shouldReportAccess(stack: string): boolean { + return this._reportAccess && !this._ignoredAccessRules.matches(stack); + } + + shouldReportInvocation(stack: string): boolean { + return ( + this._reportInvocation && !this._ignoredInvocationRules.matches(stack) + ); + } +} + +const config = new CodeInjectionConfigImpl(); +bugDetectorConfigurations.set("code-injection", config); + const canaryCache = new WeakMap(); const pendingAccesses: PendingAccess[] = []; @@ -103,18 +181,35 @@ function ensureActiveCanary(): string { function createCanaryDescriptor(canaryName: string): PropertyDescriptor { return { get() { - const pendingAccess = { canaryName, invoked: false }; - pendingAccesses.push(pendingAccess); + const accessStack = captureStack(); + const pendingAccess = config.shouldReportAccess(accessStack) + ? { + canaryName, + stack: accessStack, + invoked: false, + } + : undefined; + if (pendingAccess) { + pendingAccesses.push(pendingAccess); + } return function canaryCall() { - pendingAccess.invoked = true; - reportAndThrowFinding( - buildFindingMessage( - "Confirmed Code Injection (Canary Invoked)", - `invoked canary: ${canaryName}`, - ), - false, - ); + const invocationStack = captureStack(); + if (config.shouldReportInvocation(invocationStack)) { + if (pendingAccess) { + pendingAccess.invoked = true; + } + reportAndThrowFinding( + buildFindingMessage( + "Confirmed Code Injection (Canary Invoked)", + `invoked canary: ${canaryName}`, + invocationStack, + "ignoreInvocation", + "If this execution sink is expected in your test environment, suppress it:", + ), + false, + ); + } }; }, enumerable: false, @@ -131,12 +226,33 @@ function flushPendingAccesses(): void { buildFindingMessage( "Potential Code Injection (Canary Accessed)", `accessed canary: ${pendingAccess.canaryName}`, + pendingAccess.stack, + "ignoreAccess", + "If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:", ), false, ); } } -function buildFindingMessage(title: string, action: string): string { - return `${title} -- ${action}`; +function buildFindingMessage( + title: string, + action: string, + stack: string, + suppressionMethod: "ignoreAccess" | "ignoreInvocation", + hint: string, +): string { + const relevantStackLines = getUserFacingStackLines(stack); + const message = [`${title} -- ${action}`]; + if (relevantStackLines.length > 0) { + message.push(...relevantStackLines); + } + message.push( + "", + `[!] ${hint}`, + " Example only: copy/paste it and adapt `stackPattern` to your needs.", + "", + buildGenericSuppressionSnippet("code-injection", suppressionMethod), + ); + return message.join("\n"); } diff --git a/tests/bug-detectors/code-injection.test.js b/tests/bug-detectors/code-injection.test.js index 15990cfe..8885eb7d 100644 --- a/tests/bug-detectors/code-injection.test.js +++ b/tests/bug-detectors/code-injection.test.js @@ -93,6 +93,93 @@ describe("CLI", () => { }); } + it("prints a generic access suppression example", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("heuristicReadAccessesCanary") + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain( + 'getBugDetectorConfiguration("code-injection")', + ); + expect(fuzzTest.stderr).toContain( + "Example only: copy/paste it and adapt `stackPattern` to your needs.", + ); + expect(fuzzTest.stderr).toContain( + "// Example only: adapt `stackPattern` to the shown stack above.", + ); + expect(fuzzTest.stderr).toContain("?.ignoreAccess({"); + expect(fuzzTest.stderr).toContain('stackPattern: "test.js:10"'); + }); + + it("reports confirmed invocation when access reporting is disabled", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("evalAccessesCanary") + .customHooks(["disable-access.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + expect(fuzzTest.stderr).toContain("?.ignoreInvocation({"); + }); + + it("falls back to potential access when invocation reporting is disabled", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("evalAccessesCanary") + .customHooks(["disable-invocation.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(accessFindingMessage); + expect(fuzzTest.stderr).not.toContain(invocationFindingMessage); + }); + + it("suppresses heuristic access when a stack pattern matches", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("heuristicReadAccessesCanary") + .customHooks(["ignore-heuristic-access.config.js"]) + .build() + .execute(); + expect(fuzzTest.stdout).toContain(okMessage); + expect(fuzzTest.stderr).not.toContain(accessFindingMessage); + }); + + it("reaches invocation reporting when access is ignored by stack pattern", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("evalAccessesCanary") + .customHooks(["ignore-access-by-stack.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + }); + + it("falls back to potential access when invocation is ignored", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("evalAccessesCanary") + .customHooks(["ignore-invocation-only.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain(accessFindingMessage); + expect(fuzzTest.stderr).not.toContain(invocationFindingMessage); + }); + + it("suppresses invocation when the invocation rule matches", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("evalAccessesCanary") + .customHooks(["ignore-invocation.config.js"]) + .build(); + fuzzTest.execute(); + expect(fuzzTest.stderr).not.toContain(accessFindingMessage); + expect(fuzzTest.stderr).not.toContain(invocationFindingMessage); + }); + it("Function.prototype should still exist", () => { const fuzzTest = fuzzTestBuilder .dryRun(false) @@ -159,6 +246,33 @@ describe("Jest", () => { expect(fuzzTest.stderr).not.toContain(accessFindingMessage); }); + it("reports confirmed invocation when access reporting is disabled", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("eval Accesses canary$") + .customHooks(["disable-access.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain(invocationFindingMessage); + }); + + it("falls back to potential access when invocation reporting is disabled", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("eval Accesses canary$") + .customHooks(["disable-invocation.config.js"]) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain(accessFindingMessage); + expect(fuzzTest.stderr).not.toContain(invocationFindingMessage); + }); + it("safe code stays quiet", () => { const fuzzTest = fuzzTestBuilder .dryRun(false) diff --git a/tests/bug-detectors/code-injection/disable-access.config.js b/tests/bug-detectors/code-injection/disable-access.config.js new file mode 100644 index 00000000..30e80836 --- /dev/null +++ b/tests/bug-detectors/code-injection/disable-access.config.js @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection")?.disableAccessReporting(); diff --git a/tests/bug-detectors/code-injection/disable-invocation.config.js b/tests/bug-detectors/code-injection/disable-invocation.config.js new file mode 100644 index 00000000..d486a537 --- /dev/null +++ b/tests/bug-detectors/code-injection/disable-invocation.config.js @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection")?.disableInvocationReporting(); diff --git a/tests/bug-detectors/code-injection/ignore-access-by-stack.config.js b/tests/bug-detectors/code-injection/ignore-access-by-stack.config.js new file mode 100644 index 00000000..372750af --- /dev/null +++ b/tests/bug-detectors/code-injection/ignore-access-by-stack.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection")?.ignoreAccess({ + stackPattern: "evalAccessesCanary", +}); diff --git a/tests/bug-detectors/code-injection/ignore-heuristic-access.config.js b/tests/bug-detectors/code-injection/ignore-heuristic-access.config.js new file mode 100644 index 00000000..2efc6f24 --- /dev/null +++ b/tests/bug-detectors/code-injection/ignore-heuristic-access.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection")?.ignoreAccess({ + stackPattern: "heuristicReadAccessesCanary", +}); diff --git a/tests/bug-detectors/code-injection/ignore-invocation-only.config.js b/tests/bug-detectors/code-injection/ignore-invocation-only.config.js new file mode 100644 index 00000000..ba4049c6 --- /dev/null +++ b/tests/bug-detectors/code-injection/ignore-invocation-only.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection")?.ignoreInvocation({ + stackPattern: "evalAccessesCanary", +}); diff --git a/tests/bug-detectors/code-injection/ignore-invocation.config.js b/tests/bug-detectors/code-injection/ignore-invocation.config.js new file mode 100644 index 00000000..a1a69274 --- /dev/null +++ b/tests/bug-detectors/code-injection/ignore-invocation.config.js @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("code-injection") + ?.disableAccessReporting() + ?.ignoreInvocation({ + stackPattern: "evalAccessesCanary", + }); From 6ea62872e6ad652537edad3afcc1ca87bf14b41b Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:41:36 +0200 Subject: [PATCH 08/10] feat(path-traversal): add stack suppressions Let users silence expected traversal callsites with the same shown-stack rules used by code-injection findings. --- .../bug-detectors/internal/path-traversal.ts | 71 ++++++++++++++++++- tests/bug-detectors/path-traversal.test.js | 62 ++++++++++++++++ .../path-traversal/ignore-by-stack.config.js | 23 ++++++ .../path-traversal/ignore-fs-stack.config.js | 23 ++++++ .../path-traversal/ignore-no-match.config.js | 23 ++++++ 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 tests/bug-detectors/path-traversal/ignore-by-stack.config.js create mode 100644 tests/bug-detectors/path-traversal/ignore-fs-stack.config.js create mode 100644 tests/bug-detectors/path-traversal/ignore-no-match.config.js diff --git a/packages/bug-detectors/internal/path-traversal.ts b/packages/bug-detectors/internal/path-traversal.ts index 9d3ea25a..0c219f99 100644 --- a/packages/bug-detectors/internal/path-traversal.ts +++ b/packages/bug-detectors/internal/path-traversal.ts @@ -20,12 +20,52 @@ import { } from "@jazzer.js/core"; import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking"; +import { bugDetectorConfigurations } from "../configuration"; +import { + buildGenericSuppressionSnippet, + captureStack, + getUserFacingStackLines, + IgnoreList, + type IgnoreRule, +} from "../shared/finding-suppression"; + /** * Importing this file adds "before-hooks" for all functions in the built-in `fs`, `fs/promises`, and `path` module and guides * the fuzzer towards the uniquely chosen `goal` string `"../../jaz_zer"`. If the goal is found in the first argument * of any hooked function, a `Finding` is reported. */ const goal = "../../jaz_zer"; + +export type { IgnoreRule } from "../shared/finding-suppression"; + +/** + * Configuration for the Path Traversal bug detector. + * Controls suppression of matched path traversal findings. + */ +export interface PathTraversalConfig { + /** + * Suppresses findings that match the provided rule. + * Use this to silence known-safe callsites in your test environment. + */ + ignore(rule: IgnoreRule): this; +} + +class PathTraversalConfigImpl implements PathTraversalConfig { + private readonly _ignoredRules = new IgnoreList(); + + ignore(rule: IgnoreRule): this { + this._ignoredRules.add(rule); + return this; + } + + shouldReport(stack: string): boolean { + return !this._ignoredRules.matches(stack); + } +} + +const config = new PathTraversalConfigImpl(); +bugDetectorConfigurations.set("path-traversal", config); + const modulesToHook = [ { moduleName: "fs", @@ -208,11 +248,38 @@ function detectFindingAndGuideFuzzing( ) { const argument = input.toString(); if (argument.includes(goal)) { + const stack = captureStack(); + if (!config.shouldReport(stack)) { + return; + } reportAndThrowFinding( - "Path Traversal\n" + - ` in ${functionName}(): called with '${argument}'`, + buildFindingMessage(functionName, argument, stack), + false, ); } guideTowardsContainment(argument, goal, hookId); } } + +function buildFindingMessage( + functionName: string, + argument: string, + stack: string, +): string { + const relevantStackLines = getUserFacingStackLines(stack); + const message = [ + "Path Traversal", + ` in ${functionName}(): called with '${argument}'`, + ]; + if (relevantStackLines.length > 0) { + message.push(...relevantStackLines); + } + message.push( + "", + "[!] If this callsite is expected in your test environment, suppress it:", + " Example only: copy/paste it and adapt `stackPattern` to your needs.", + "", + buildGenericSuppressionSnippet("path-traversal", "ignore"), + ); + return message.join("\n"); +} diff --git a/tests/bug-detectors/path-traversal.test.js b/tests/bug-detectors/path-traversal.test.js index a45cd501..83490e2a 100644 --- a/tests/bug-detectors/path-traversal.test.js +++ b/tests/bug-detectors/path-traversal.test.js @@ -27,9 +27,11 @@ describe("Path Traversal", () => { const SAFE = "../safe_path/"; const EVIL = "../evil_path/"; const bugDetectorDirectory = path.join(__dirname, "path-traversal"); + const goalPath = path.join(bugDetectorDirectory, "../../jaz_zer"); beforeEach(async () => { fs.rmSync(SAFE, { recursive: true, force: true }); + fs.rmSync(goalPath, { recursive: true, force: true }); await cleanCrashFilesIn(bugDetectorDirectory); }); @@ -190,6 +192,66 @@ describe("Path Traversal", () => { .build(); fuzzTest.execute(); }); + + it("prints a generic suppression example", () => { + const fuzzTest = new FuzzTestBuilder() + .runs(0) + .sync(true) + .fuzzEntryPoint("PathTraversalFsMkdirEvilSync") + .dir(bugDetectorDirectory) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain( + 'getBugDetectorConfiguration("path-traversal")', + ); + expect(fuzzTest.stderr).toContain( + "Example only: copy/paste it and adapt `stackPattern` to your needs.", + ); + expect(fuzzTest.stderr).toContain( + "// Example only: adapt `stackPattern` to the shown stack above.", + ); + expect(fuzzTest.stderr).toContain("?.ignore({"); + expect(fuzzTest.stderr).toContain('stackPattern: "test.js:10"'); + }); + + it("suppresses findings when a stack pattern matches the fs callsite", () => { + const fuzzTest = new FuzzTestBuilder() + .runs(0) + .sync(true) + .fuzzEntryPoint("PathTraversalFsMkdirEvilSync") + .customHooks(["ignore-fs-stack.config.js"]) + .dir(bugDetectorDirectory) + .build(); + fuzzTest.execute(); + expect(fs.existsSync(goalPath)).toBeTruthy(); + }); + + it("suppresses findings when stack pattern matches", () => { + const fuzzTest = new FuzzTestBuilder() + .runs(0) + .sync(true) + .fuzzEntryPoint("PathTraversalJoinEvilSync") + .customHooks(["ignore-by-stack.config.js"]) + .dir(bugDetectorDirectory) + .build(); + fuzzTest.execute(); + }); + + it("still reports when ignore rule does not match", () => { + const fuzzTest = new FuzzTestBuilder() + .runs(0) + .sync(true) + .fuzzEntryPoint("PathTraversalJoinEvilSync") + .customHooks(["ignore-no-match.config.js"]) + .dir(bugDetectorDirectory) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain("Path Traversal"); + }); }); describe("Path Traversal invalid input", () => { diff --git a/tests/bug-detectors/path-traversal/ignore-by-stack.config.js b/tests/bug-detectors/path-traversal/ignore-by-stack.config.js new file mode 100644 index 00000000..c2bfbd8f --- /dev/null +++ b/tests/bug-detectors/path-traversal/ignore-by-stack.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("path-traversal")?.ignore({ + stackPattern: "Object.join", +}); diff --git a/tests/bug-detectors/path-traversal/ignore-fs-stack.config.js b/tests/bug-detectors/path-traversal/ignore-fs-stack.config.js new file mode 100644 index 00000000..b6581a3f --- /dev/null +++ b/tests/bug-detectors/path-traversal/ignore-fs-stack.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("path-traversal")?.ignore({ + stackPattern: "at fn (", +}); diff --git a/tests/bug-detectors/path-traversal/ignore-no-match.config.js b/tests/bug-detectors/path-traversal/ignore-no-match.config.js new file mode 100644 index 00000000..237ece9a --- /dev/null +++ b/tests/bug-detectors/path-traversal/ignore-no-match.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + getBugDetectorConfiguration, +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("path-traversal")?.ignore({ + stackPattern: "never-match", +}); From ea1fd20c302def2239a80774e5e73be599b89c3f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 00:45:01 +0200 Subject: [PATCH 09/10] test(path-traversal): clean generated paths Use the same filesystem locations as the spawned fuzz targets so repeated direct runs do not fail on stale SAFE directories. --- tests/bug-detectors/path-traversal.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bug-detectors/path-traversal.test.js b/tests/bug-detectors/path-traversal.test.js index 83490e2a..d1692ff1 100644 --- a/tests/bug-detectors/path-traversal.test.js +++ b/tests/bug-detectors/path-traversal.test.js @@ -24,9 +24,9 @@ const { } = require("../helpers"); describe("Path Traversal", () => { - const SAFE = "../safe_path/"; - const EVIL = "../evil_path/"; const bugDetectorDirectory = path.join(__dirname, "path-traversal"); + const SAFE = path.join(bugDetectorDirectory, "../../safe_path"); + const EVIL = path.join(bugDetectorDirectory, "../../evil_path"); const goalPath = path.join(bugDetectorDirectory, "../../jaz_zer"); beforeEach(async () => { From 9b1bb8c4b204a76cb036cb833df76f5f99a458b0 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Fri, 24 Apr 2026 18:44:16 +0200 Subject: [PATCH 10/10] docs(bug-detectors): document detector suppressions Keep the user-facing detector docs together after adding code-injection and stack-based suppressions. --- docs/bug-detectors.md | 79 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/bug-detectors.md b/docs/bug-detectors.md index 4c9d5c9e..fef1d37e 100644 --- a/docs/bug-detectors.md +++ b/docs/bug-detectors.md @@ -25,6 +25,30 @@ using Jest in `.jazzerjsrc.json`: Hooks all relevant functions of the built-in modules `fs` and `path` and reports a finding if the fuzzer could pass a special path to any of the functions. +The Path Traversal bug detector can be configured in the +[custom hooks](./fuzz-settings.md#customhooks--arraystring) file. + +- `ignore(rule)` - suppresses findings from callsites matching the shown stack + excerpt. +- `stackPattern` accepts either a string or a `RegExp` and is matched against + the shown stack excerpt after removing the leading `Error` line and Jazzer.js + frames. The remaining stack text is matched as shown, including path + separators and column numbers. + +Here is an example configuration in the +[custom hooks](./fuzz-settings.md#customhooks--arraystring) file: + +```javascript +const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors"); + +getBugDetectorConfiguration("path-traversal")?.ignore({ + stackPattern: "safe-path-wrapper.js:41", +}); +``` + +Findings also print a generic example suppression snippet. Copy/paste it and +adapt `stackPattern` to the shown stack excerpt. + _Disable with:_ `--disableBugDetectors=path-traversal` in CLI mode; or when using Jest in `.jazzerjsrc.json`: @@ -98,17 +122,58 @@ using Jest in `.jazzerjsrc.json`: { "disableBugDetectors": ["prototype-pollution"] } ``` -## Remote Code Execution +## Code Injection + +Installs a canary on `globalThis` and hooks the `eval` and `Function` functions. +The before-hooks guide the fuzzer toward injecting the active canary identifier +into code strings. The detector reports two fatal stages by default: -Hooks the `eval` and `Function` functions and reports a finding if the fuzzer -was able to pass a special string to `eval` and to the function body of -`Function`. +- `Potential Code Injection (Canary Accessed)` - some code resolved the canary. + This high-recall heuristic catches cases where dynamically produced code reads + or stores the canary before executing it later. +- `Confirmed Code Injection (Canary Invoked)` - the callable canary returned by + the getter was invoked. -_Disable with:_ `--disableBugDetectors=remote-code-execution` in CLI mode; or -when using Jest in `.jazzerjsrc.json`: +The detector can be configured in the +[custom hooks](./fuzz-settings.md#customhooks--arraystring) file. + +- `disableAccessReporting` - disables the stage-1 access finding while keeping + invocation reporting active. +- `disableInvocationReporting` - disables the stage-2 invocation finding. +- `ignoreAccess(rule)` - suppresses stage-1 findings matching the shown stack + excerpt. +- `ignoreInvocation(rule)` - suppresses stage-2 findings matching the shown + stack excerpt. +- `stackPattern` accepts either a string or a `RegExp` and is matched against + the shown stack excerpt after removing the leading `Error` line and Jazzer.js + frames. The remaining stack text is matched as shown, including path + separators and column numbers. + +The detector must be able to install a canary on at least one active global +object. Locked-down environments that forbid this should disable the detector +explicitly. + +Here is an example configuration in the +[custom hooks](./fuzz-settings.md#customhooks--arraystring) file: + +```javascript +const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors"); + +getBugDetectorConfiguration("code-injection") + ?.ignoreAccess({ + stackPattern: "handlebars/runtime.js:87", + }) + ?.disableInvocationReporting(); +``` + +Findings print a generic example suppression snippet. Copy/paste it and adapt +`stackPattern` to a stable substring or `RegExp` from the shown stack. + +_Disable with:_ `--disableBugDetectors=code-injection` in CLI mode; or when +using Jest in `.jazzerjsrc.json`: ```json -{ "disableBugDetectors": ["remote-code-execution"] } +{ "disableBugDetectors": ["code-injection"] } ``` ## Server-Side Request Forgery (SSRF)