diff --git a/docs/bug-detectors.md b/docs/bug-detectors.md index 4c9d5c9e0..17c2e1aa4 100644 --- a/docs/bug-detectors.md +++ b/docs/bug-detectors.md @@ -98,17 +98,48 @@ using Jest in `.jazzerjsrc.json`: { "disableBugDetectors": ["prototype-pollution"] } ``` -## Remote Code Execution +## Code Injection -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`. +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: -_Disable with:_ `--disableBugDetectors=remote-code-execution` in CLI mode; or -when using Jest in `.jazzerjsrc.json`: +- `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. + +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 a file, function, + or stack pattern. +- `ignoreInvocation(rule)` - suppresses stage-2 findings matching a file, + function, or stack pattern. + +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({ + filePattern: /handlebars[\\/]dist[\\/]cjs[\\/]runtime\.js$/, + functionPattern: /^lookupProperty$/, + }) + ?.disableInvocationReporting(); +``` + +_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) diff --git a/packages/bug-detectors/internal/code-injection.ts b/packages/bug-detectors/internal/code-injection.ts new file mode 100644 index 000000000..ea2d68011 --- /dev/null +++ b/packages/bug-detectors/internal/code-injection.ts @@ -0,0 +1,437 @@ +/* + * 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, + reportAndThrowFinding, +} from "@jazzer.js/core"; +import { registerBeforeHook } from "@jazzer.js/hooking"; + +import { bugDetectorConfigurations } from "../configuration"; + +const BASE_CANARY_NAME = "jaz_zer"; +const MAX_STACK_LINES = 8; +const JAZZER_INTERNAL_STACK_MARKERS = [ + "/@jazzer.js/", + "/jazzer.js/packages/", + "/jazzer.js/core/", + "/jazzer.js-commercial/packages/", + "/jazzer.js-commercial/core/", + "../../packages/", +]; + +export interface IgnoreRule { + filePattern?: RegExp; + functionPattern?: RegExp; + stackPattern?: RegExp; +} + +type OriginFrame = { + stackLine: string; + functionName?: string; + filePath?: string; +}; + +export class CodeInjectionConfig { + private _reportAccess = true; + private _reportInvocation = true; + private readonly _ignoredAccessRules: IgnoreRule[] = []; + private readonly _ignoredInvocationRules: IgnoreRule[] = []; + + disableAccessReporting(): this { + this._reportAccess = false; + return this; + } + + disableInvocationReporting(): this { + this._reportInvocation = false; + return this; + } + + ignoreAccess(rule: IgnoreRule): this { + this._ignoredAccessRules.push(rule); + return this; + } + + ignoreInvocation(rule: IgnoreRule): this { + this._ignoredInvocationRules.push(rule); + return this; + } + + shouldReportAccess( + originFrame: OriginFrame | undefined, + stack: string, + ): boolean { + return ( + this._reportAccess && + !this.matchesAnyRule(this._ignoredAccessRules, originFrame, stack) + ); + } + + shouldReportInvocation( + originFrame: OriginFrame | undefined, + stack: string, + ): boolean { + return ( + this._reportInvocation && + !this.matchesAnyRule(this._ignoredInvocationRules, originFrame, stack) + ); + } + + private matchesAnyRule( + rules: IgnoreRule[], + originFrame: OriginFrame | undefined, + stack: string, + ): boolean { + return rules.some((rule) => this.matchesRule(rule, originFrame, stack)); + } + + private matchesRule( + rule: IgnoreRule, + originFrame: OriginFrame | undefined, + stack: string, + ): boolean { + if (rule.stackPattern && !matchesPattern(rule.stackPattern, stack)) { + return false; + } + if (rule.filePattern) { + if (!originFrame?.filePath) { + return false; + } + if (!matchesPattern(rule.filePattern, originFrame.filePath)) { + return false; + } + } + if (rule.functionPattern) { + if (!originFrame?.functionName) { + return false; + } + if (!matchesPattern(rule.functionPattern, originFrame.functionName)) { + return false; + } + } + return Boolean( + rule.stackPattern || rule.filePattern || rule.functionPattern, + ); + } +} + +const config = new CodeInjectionConfig(); +bugDetectorConfigurations.set("code-injection", config); + +const installedCanaries = new WeakMap(); + +ensureKnownCanariesInstalled(); + +registerBeforeHook( + "eval", + "", + false, + function beforeEvalHook( + _thisPtr: unknown, + params: unknown[], + hookId: number, + ) { + ensureKnownCanariesInstalled(); + + const code = params[0]; + if (typeof code === "string") { + guideTowardsContainment(code, getActiveCanaryName(), hookId); + } + }, +); + +registerBeforeHook( + "Function", + "", + false, + function beforeFunctionHook( + _thisPtr: unknown, + params: unknown[], + hookId: number, + ) { + ensureKnownCanariesInstalled(); + if (params.length === 0) return; + + const functionBody = params[params.length - 1]; + if (functionBody == null) return; + + let functionBodySource: string; + try { + functionBodySource = String(functionBody); + } catch { + return; + } + + guideTowardsContainment(functionBodySource, getActiveCanaryName(), hookId); + }, +); + +function ensureKnownCanariesInstalled(): void { + ensureCanaryInstalled(globalThis); + const vmContext = getVmContext(); + if (vmContext) { + ensureCanaryInstalled(vmContext); + } +} + +function getVmContext(): Context | undefined { + return getJazzerJsGlobal("vmContext"); +} + +function getActiveCanaryName(): string { + const vmContext = getVmContext(); + return vmContext + ? ensureCanaryInstalled(vmContext) + : ensureCanaryInstalled(globalThis); +} + +function ensureCanaryInstalled(target: object): string { + const knownCanaryName = installedCanaries.get(target); + 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, createCanaryDescriptor(canaryName)); + installedCanaries.set(target, canaryName); + return canaryName; +} + +function createCanaryDescriptor(canaryName: string): PropertyDescriptor { + return { + get() { + const accessStack = captureStack(); + const accessOrigin = parseOriginFrame(accessStack); + if (config.shouldReportAccess(accessOrigin, accessStack)) { + reportAndThrowFinding( + buildFindingMessage( + "Potential Code Injection (Canary Accessed)", + `accessed canary: ${canaryName}`, + accessStack, + accessOrigin, + "ignoreAccess", + "If this is a safe heuristic read, suppress it to continue fuzzing for code execution. Add this to your custom hooks:", + ), + false, + ); + } + + return function canaryCall() { + const invocationStack = captureStack(); + const invocationOrigin = parseOriginFrame(invocationStack); + if (config.shouldReportInvocation(invocationOrigin, invocationStack)) { + reportAndThrowFinding( + buildFindingMessage( + "Confirmed Code Injection (Canary Invoked)", + `invoked canary: ${canaryName}`, + invocationStack, + invocationOrigin, + "ignoreInvocation", + "If this execution sink is expected in your test environment, suppress it:", + ), + false, + ); + } + }; + }, + enumerable: false, + configurable: false, + }; +} + +function buildFindingMessage( + title: string, + action: string, + stack: string, + originFrame: OriginFrame | undefined, + suppressionMethod: "ignoreAccess" | "ignoreInvocation", + hint: string, +): string { + const relevantStackLines = getRelevantStackLines(stack).slice( + 0, + MAX_STACK_LINES, + ); + const message = [`${title} -- ${action}`]; + if (relevantStackLines.length > 0) { + message.push(...relevantStackLines); + } + message.push( + "", + `[!] ${hint}`, + "", + buildSuppressionSnippet(suppressionMethod, originFrame, stack), + ); + return message.join("\n"); +} + +function buildSuppressionSnippet( + suppressionMethod: "ignoreAccess" | "ignoreInvocation", + originFrame: OriginFrame | undefined, + stack: string, +): string { + const ruleLines: string[] = []; + const filePattern = + originFrame?.filePath && buildFilePattern(originFrame.filePath); + if (filePattern) { + ruleLines.push(`filePattern: ${filePattern}`); + } + if (originFrame?.functionName) { + ruleLines.push( + `functionPattern: ${buildExactRegex(originFrame.functionName)}`, + ); + } + if (ruleLines.length === 0) { + ruleLines.push(`stackPattern: ${buildStackPattern(stack)}`); + } + + return [ + 'const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors");', + "", + 'getBugDetectorConfiguration("code-injection")', + ` .${suppressionMethod}({`, + ...ruleLines.map( + (line, index) => ` ${line}${index < ruleLines.length - 1 ? "," : ""}`, + ), + " });", + ].join("\n"); +} + +function buildFilePattern(filePath: string): string | undefined { + if (filePath.startsWith("node:")) { + return undefined; + } + + const normalized = filePath.replace(/\\/g, "/"); + let parts = normalized.split("/").filter(Boolean); + if (parts.length === 0) { + return undefined; + } + if (/^[A-Za-z]:$/.test(parts[0])) { + parts = parts.slice(1); + } + + const nodeModulesIndex = parts.indexOf("node_modules"); + if (nodeModulesIndex !== -1) { + parts = parts.slice(nodeModulesIndex + 1); + } else { + parts = parts.slice(-Math.min(2, parts.length)); + } + + return `/${parts.map(escapeRegex).join("[\\\\/]")}$/`; +} + +function buildExactRegex(text: string): string { + return `/^${escapeRegex(text)}$/`; +} + +function buildStackPattern(stack: string): string { + const stackLine = getRelevantStackLines(stack)[0] ?? " at "; + const normalized = stackLine + .replace(/:\d+:\d+/g, "") + .replace(/^\s*at\s+/, "") + .trim(); + return `/${escapeRegex(normalized)}/`; +} + +function captureStack(): string { + return new Error().stack ?? ""; +} + +function parseOriginFrame(stack: string): OriginFrame | undefined { + const stackLine = getRelevantStackLines(stack)[0]; + if (!stackLine) { + return undefined; + } + + const withFunctionMatch = stackLine.match(/^\s*at\s+(.+?)\s+\((.+)\)$/); + if (withFunctionMatch) { + return { + stackLine, + functionName: normalizeFunctionName(withFunctionMatch[1]), + filePath: extractFilePath(withFunctionMatch[2]), + }; + } + + const withoutFunctionMatch = stackLine.match(/^\s*at\s+(.+)$/); + if (withoutFunctionMatch) { + return { + stackLine, + functionName: "anonymous", + filePath: extractFilePath(withoutFunctionMatch[1]), + }; + } + + return { stackLine }; +} + +function normalizeFunctionName(functionName: string): string { + const trimmed = functionName.trim(); + if (trimmed.length === 0 || trimmed === "") { + return "anonymous"; + } + return trimmed; +} + +function extractFilePath(location: string): string | undefined { + const evalMatch = location.match( + /eval at .*?\((.+):\d+:\d+\), :\d+:\d+$/, + ); + if (evalMatch) { + return evalMatch[1]; + } + + const directMatch = location.match(/^(.*):\d+:\d+$/); + if (directMatch && !directMatch[1].includes("")) { + return directMatch[1]; + } + + return undefined; +} + +function getRelevantStackLines(stack: string): string[] { + return stack + .split("\n") + .slice(1) + .map((line) => line.replace(/^\s*at\s+/, " at ")) + .filter((line) => line.trim().length > 0) + .filter((line) => !isJazzerInternalStackLine(line)); +} + +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 escapeRegex(value: string): string { + return value.replace(/[|\\{}()[\]^$+*?./]/g, "\\$&"); +} 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 fbe724e31..000000000 --- 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/core/cli.ts b/packages/core/cli.ts index 13ccdf5bb..3ee9a8790 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/packages/core/core.ts b/packages/core/core.ts index 4e7c0e8f3..a865e8983 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -199,9 +199,10 @@ function getFilteredBugDetectorPaths( const bugDetectorName = path.basename(bugDetectorPath, ".js"); // Checks in the global options if the bug detector should be loaded. - const shouldDisable = disablePatterns.some((pattern) => - pattern.test(bugDetectorName), - ); + const shouldDisable = disablePatterns.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(bugDetectorName); + }); if (shouldDisable) { console.error( diff --git a/packages/jest-runner/corpus.test.ts b/packages/jest-runner/corpus.test.ts index fcec3eae3..f089061ae 100644 --- a/packages/jest-runner/corpus.test.ts +++ b/packages/jest-runner/corpus.test.ts @@ -95,7 +95,28 @@ describe("Corpus", () => { it("throw error if no package.json was found", () => { const fuzzTest = mockFuzzTest({ generatePackageJson: false }); - expect(() => new Corpus(fuzzTest, [])).toThrow(); + const originalReaddirSync = (directoryPath: fs.PathLike): string[] => + fs.readdirSync(directoryPath); + const readdirSync = jest.spyOn(fs, "readdirSync"); + readdirSync.mockImplementation(((directoryPath: fs.PathLike) => { + if (typeof directoryPath === "string") { + const fuzzTestDir = path.dirname(fuzzTest); + if ( + directoryPath === fuzzTestDir || + directoryPath === path.dirname(fuzzTestDir) || + directoryPath === path.parse(fuzzTestDir).root + ) { + return []; + } + } + return originalReaddirSync(directoryPath); + }) as unknown as typeof fs.readdirSync); + + try { + expect(() => new Corpus(fuzzTest, [])).toThrow(); + } finally { + readdirSync.mockRestore(); + } }); }); }); diff --git a/tests/bug-detectors/code-injection.test.js b/tests/bug-detectors/code-injection.test.js new file mode 100644 index 000000000..96fce503d --- /dev/null +++ b/tests/bug-detectors/code-injection.test.js @@ -0,0 +1,197 @@ +/* + * 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, "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 accessFindingCases = [ + "evalAccessesCanary", + "evalIndirectAccessesCanary", + "evalCommaOperatorAccessesCanary", + "evalOptionalChainingAccessesCanary", + "heuristicReadAccessesCanary", + "functionAccessesCanary", + "functionNewAccessesCanary", + "functionWithArgAccessesCanary", + "functionStringCoercibleAccessesCanary", + ]; + + const noFindingCases = [ + "evalSafeCode", + "evalTargetInStringLiteral", + "functionSafeCode", + "functionSafeCodeNew", + "functionTargetInArgName", + "functionTargetInStringLiteral", + "functionStringCoercibleSafe", + ]; + + 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); + }); + } + + for (const entryPoint of noFindingCases) { + it(`${entryPoint} stays quiet`, () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint(entryPoint) + .build() + .execute(); + expect(fuzzTest.stdout).toContain(okMessage); + }); + } + + it("prints a copy-paste access suppression snippet", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("heuristicReadAccessesCanary") + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(FuzzingExitCode); + expect(fuzzTest.stderr).toContain( + 'getBugDetectorConfiguration("code-injection")', + ); + expect(fuzzTest.stderr).toContain(".ignoreAccess({"); + expect(fuzzTest.stderr).toContain( + "filePattern: /code-injection[\\\\/]fuzz\\.js$/", + ); + expect(fuzzTest.stderr).toContain( + "functionPattern: /^module\\.exports\\.heuristicReadAccessesCanary$/", + ); + }); + + 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("suppresses heuristic access when file and function both match", () => { + const fuzzTest = fuzzTestBuilder + .fuzzEntryPoint("heuristicReadAccessesCanary") + .customHooks(["ignore-access-by-frame.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("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) + .fuzzEntryPoint("functionPrototypeExists") + .build(); + fuzzTest.execute(); + }); +}); + +describe("Jest", () => { + it("reports potential access", () => { + const fuzzTest = fuzzTestBuilder + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("eval Accesses canary$") + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(JestRegressionExitCode); + expect(fuzzTest.stderr).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("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/disable-access.config.js b/tests/bug-detectors/code-injection/disable-access.config.js new file mode 100644 index 000000000..30e80836c --- /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/fuzz.js b/tests/bug-detectors/code-injection/fuzz.js new file mode 100644 index 000000000..81e0d64cb --- /dev/null +++ b/tests/bug-detectors/code-injection/fuzz.js @@ -0,0 +1,94 @@ +/* + * 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)(); +}; + +// --- 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/code-injection/ignore-access-by-frame.config.js b/tests/bug-detectors/code-injection/ignore-access-by-frame.config.js new file mode 100644 index 000000000..12b692fb4 --- /dev/null +++ b/tests/bug-detectors/code-injection/ignore-access-by-frame.config.js @@ -0,0 +1,24 @@ +/* + * 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({ + filePattern: /code-injection[\\/]fuzz\.js$/, + functionPattern: /^module\.exports\.heuristicReadAccessesCanary$/, +}); 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 000000000..5e54927b9 --- /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-invocation.config.js b/tests/bug-detectors/code-injection/ignore-invocation.config.js new file mode 100644 index 000000000..4d610abbb --- /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({ + functionPattern: /^eval$/, + }); 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 fcb22af75..3deb157a7 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 000000000..fc18a4e59 --- /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 9d251eb3a..000000000 --- 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 daab6b959..000000000 --- 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 a7a7a3a88..000000000 --- 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); - }); -});