From 9a65ce70ae3832b528d355cf1697b390e7350709 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 25 May 2023 18:18:51 +0200 Subject: [PATCH 1/5] Bug detectors: add Prototype Pollution --- .gitignore | 5 +- docs/fuzz-settings.md | 92 +++- .../command-injection/custom-hooks.js | 8 +- .../command-injection/package.json | 4 +- examples/bug-detectors/package.json | 13 + .../bug-detectors/path-traversal/package.json | 6 +- .../prototype-pollution/config.js | 9 + .../bug-detectors/prototype-pollution/fuzz.js | 26 + .../prototype-pollution/package.json | 16 + .../prototype-pollution/userDict.txt | 1 + packages/bug-detectors/DEVELOPMENT.md | 158 ++++++ packages/bug-detectors/README.md | 19 + packages/bug-detectors/configuration.ts | 37 ++ packages/bug-detectors/index.ts | 77 ++- .../internal/command-injection.ts | 4 +- .../bug-detectors/internal/path-traversal.ts | 12 +- .../internal/prototype-pollution.ts | 484 ++++++++++++++++++ packages/core/cli.ts | 2 + packages/core/core.ts | 119 ++++- packages/core/jazzer.ts | 18 +- packages/fuzzer/fuzzer.ts | 57 --- packages/fuzzer/trace.ts | 67 +++ packages/hooking/manager.ts | 95 ++++ packages/instrumentor/instrument.ts | 6 +- packages/jest-runner/config.ts | 8 +- packages/jest-runner/fuzz.ts | 6 +- packages/jest-runner/index.ts | 3 +- tests/bug-detectors/general/package.json | 4 +- tests/bug-detectors/package.json | 3 +- .../bug-detectors/prototype-pollution.test.js | 447 ++++++++++++++++ .../prototype-pollution/01-UserDictionary.txt | 2 + .../prototype-pollution/02-UserDictionary.txt | 3 + .../bug-detectors/prototype-pollution/fuzz.js | 109 ++++ .../instrument-all-exclude-one.config.js | 23 + .../instrument-all.config.js | 23 + .../instrumentation-correctness-tests.js | 59 +++ .../prototype-pollution/package.json | 27 + .../prototype-pollution/tests.fuzz.js | 37 ++ .../Assignments/empty | 0 .../Pollution_of_Object/empty | 0 .../Variable_declarations/empty | 0 tests/helpers.js | 136 +++-- 42 files changed, 2044 insertions(+), 181 deletions(-) create mode 100644 examples/bug-detectors/package.json create mode 100644 examples/bug-detectors/prototype-pollution/config.js create mode 100644 examples/bug-detectors/prototype-pollution/fuzz.js create mode 100644 examples/bug-detectors/prototype-pollution/package.json create mode 100644 examples/bug-detectors/prototype-pollution/userDict.txt create mode 100644 packages/bug-detectors/DEVELOPMENT.md create mode 100644 packages/bug-detectors/README.md create mode 100644 packages/bug-detectors/configuration.ts create mode 100644 packages/bug-detectors/internal/prototype-pollution.ts create mode 100644 tests/bug-detectors/prototype-pollution.test.js create mode 100644 tests/bug-detectors/prototype-pollution/01-UserDictionary.txt create mode 100644 tests/bug-detectors/prototype-pollution/02-UserDictionary.txt create mode 100644 tests/bug-detectors/prototype-pollution/fuzz.js create mode 100644 tests/bug-detectors/prototype-pollution/instrument-all-exclude-one.config.js create mode 100644 tests/bug-detectors/prototype-pollution/instrument-all.config.js create mode 100644 tests/bug-detectors/prototype-pollution/instrumentation-correctness-tests.js create mode 100644 tests/bug-detectors/prototype-pollution/package.json create mode 100644 tests/bug-detectors/prototype-pollution/tests.fuzz.js create mode 100644 tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Assignments/empty create mode 100644 tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Pollution_of_Object/empty create mode 100644 tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Variable_declarations/empty diff --git a/.gitignore b/.gitignore index db6659d55..92c55f10d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ node_modules/ *.tgz # corpus files in the path traversal example except for manual test.zip -examples/bug-detectors/path-traversal/corpus/ \ No newline at end of file +examples/bug-detectors/path-traversal/corpus/ + +.JazzerJs-merged-dictionaries +tests/bug-detectors/*/.jazzerjsrc.json \ No newline at end of file diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 69f757ed7..6ee70f85f 100644 --- a/docs/fuzz-settings.md +++ b/docs/fuzz-settings.md @@ -196,15 +196,95 @@ Bug detectors are one of the key features when fuzzing memory-safe languages. In Jazzer.js, they can detect some of the most common vulnerabilities in JavaScript code. Built-in bug detectors are enabled by default, but can be disabled by adding the `--disable_bug_detectors=` flag to the project -configuration. For example, to disable all built-in bug detectors, add +configuration. To disable all built-in bug detectors, add `--disable_bug_detectors='.*'` to the project configuration. -Following built-in bug detectors are available in Jazzer.js: +### Command Injection -| Bug Detector | Description | -| ------------------- | -------------------------------------------------------------------- | -| `command-injection` | Hooks all functions of the built-in module `child_process`. | -| `path-traversal` | Hooks all relevant functions of the built-in modules `fs` and `path` | +Hooks all functions of the built-in module `child_process` and reports a finding +if the fuzzer was able to pass a command to any of the functions. + +_Disable with:_ `--disable_bug_detectors=command-injection`, or when using Jest: + +```json +{ "disableBugDetectors": ["command-injection"] } +``` + +### Path Traversal + +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. + +_Disable with:_ `--disable_bug_detectors=path-traversal`, or when using Jest: + +```json +{ "disableBugDetectors": ["path-traversal"] } +``` + +### Prototype Pollution + +Detects Prototype Pollution. Prototype Pollution is a vulnerability that allows +attackers to modify the prototype of a JavaScript object, which can lead to +validation bypass, denial of service and arbitrary code execution. + +The Prototype Pollution bug detector can be configured in the +[custom hooks](#custom-hooks) file. + +- `instrumentAssignmentsAndVariableDeclarations` - if called, the bug detector + will instrument assignment expressions and variable declarations and report a + finding if `__proto__` of the declared or assigned variable contains any + properties or methods. When called in dry run mode, this option will trigger + an error. +- `addExcludedExactMatch` - if the stringified `__proto__` equals the given + string, the bug detector will not report a finding. This is useful to exclude + false positives. + +Here is an example configuration in the [custom hooks](#custom-hooks) file: + +```javascript +const { getBugDetectorConfiguration } = require("@jazzer.js/bug-detectors"); + +getBugDetectorConfiguration("prototype-pollution") + ?.instrumentAssignmentsAndVariableDeclarations() + ?.addExcludedExactMatch('{"methods":{}}'); +``` + +Adding instrumentation to variable declarations and assignment expressions +drastically reduces the fuzzer's performance because the fuzzer will check for +non-empty `__proto__` on every variable declaration and assignment expression. +In addition, this might cause false positives because some libraries (e.g. +`lodash`) use `__proto__` to store methods. Therefore, in the default +configuration these options are disabled. + +_Shortcoming:_ The instrumentation of variable declarations and assignment +expressions will not detect if the prototype of the object in question has new, +deleted, or modified functions. But it will detect if a function of a prototype +of an object has become a non-function. The following example illustrates this +issue: + +```javascript +class A {} +class B extends A {} +const b = new B(); +b.__proto__.polluted = true; // will be detected +b.__proto__.test = [1, 2, 3]; // will be detected +b.__proto__.toString = 10; // will be detected +b.__proto__.toString = () => "polluted"; // will not be detected +delete b.__proto__.toString; // will not be detected +b.__proto__.hello = () => "world"; // will not be detected +``` + +However, our assumption is that if the fuzzer is able to modify the methods in a +prototype, it will be able also find a way to modify other properties of the +prototype that are not functions. If you find a use case where this assumption +does not hold, feel free to open an issue. + +_Disable with:_ `--disable_bug_detectors=prototype-pollution`, or when using +Jest: + +```json +{ "disableBugDetectors": ["prototype-pollution"] } +``` For implementation details see [../packages/bug-detectors/internal](../packages/bug-detectors/internal). diff --git a/examples/bug-detectors/command-injection/custom-hooks.js b/examples/bug-detectors/command-injection/custom-hooks.js index 3fae17b05..c82cc7373 100644 --- a/examples/bug-detectors/command-injection/custom-hooks.js +++ b/examples/bug-detectors/command-injection/custom-hooks.js @@ -18,10 +18,12 @@ const { registerReplaceHook } = require("@jazzer.js/hooking"); const { reportFinding } = require("@jazzer.js/bug-detectors"); -const { guideTowardsEquality } = require("@jazzer.js/fuzzer"); +const { fuzzer } = require("@jazzer.js/fuzzer"); /** - * Custom bug detector for command injection. + * Custom bug detector for command injection. This hook does not call the original function (execSync) for two reasons: + * 1. To speed up fuzzing---calling execSync gives us about 5 executions per second, while calling nothing gives us a lot more. + * 2. To prevent the fuzzer from accidentally calling commands like "rm -rf" on the host system during local tests. */ registerReplaceHook( "execSync", @@ -37,6 +39,6 @@ registerReplaceHook( `Command Injection in spawnSync(): called with '${command}'`, ); } - guideTowardsEquality(command, "jaz_zer", hookId); + fuzzer.tracer.guideTowardsEquality(command, "jaz_zer", hookId); }, ); diff --git a/examples/bug-detectors/command-injection/package.json b/examples/bug-detectors/command-injection/package.json index 3a4af53da..edb791805 100644 --- a/examples/bug-detectors/command-injection/package.json +++ b/examples/bug-detectors/command-injection/package.json @@ -1,5 +1,5 @@ { - "name": "bug-detectors", + "name": "command-injection-example", "version": "1.0.0", "main": "fuzz.js", "license": "ISC", @@ -7,7 +7,7 @@ "global-modules-path": "^2.3.1" }, "scripts": { - "customHooks": "jazzer fuzz -i global-modules-path --disable_bug_detectors='.*' -h custom-hooks --timeout=100000000 --sync -- -runs=100000 -print_final_stats=1", + "fuzz": "jazzer fuzz -i global-modules-path --disable_bug_detectors='.*' -h custom-hooks --timeout=100000000 --sync -x Error -- -runs=100000 -print_final_stats=1", "bugDetectors": "jazzer fuzz -i global-modules-path --timeout=100000000 --sync -- -runs=100000 -print_final_stats=1", "dryRun": "jazzer fuzz --sync -x Error -- -runs=100000 -seed=123456789" }, diff --git a/examples/bug-detectors/package.json b/examples/bug-detectors/package.json new file mode 100644 index 000000000..c9d439e89 --- /dev/null +++ b/examples/bug-detectors/package.json @@ -0,0 +1,13 @@ +{ + "name": "examples-bug-detectors", + "version": "1.0.0", + "scripts": { + "dryRun": "npm run test", + "test": "run-script-os", + "test:linux:darwin": "sh ../../scripts/run_all.sh fuzz", + "test:win32": "..\\..\\scripts\\run_all.bat fuzz" + }, + "devDependencies": { + "run-script-os": "^1.1.6" + } +} diff --git a/examples/bug-detectors/path-traversal/package.json b/examples/bug-detectors/path-traversal/package.json index 1fcd94d5b..dd37d5953 100644 --- a/examples/bug-detectors/path-traversal/package.json +++ b/examples/bug-detectors/path-traversal/package.json @@ -1,5 +1,5 @@ { - "name": "custom-hooks-bd", + "name": "path-traversal-example", "version": "1.0.0", "main": "fuzz.js", "license": "ISC", @@ -7,10 +7,10 @@ "jszip": "3.7.1" }, "scripts": { - "fuzz": "jazzer fuzz -i fuzz.js -i jszip corpus -- -runs=10000000 -print_final_stats=1 -use_value_profile=1 -max_len=600 -seed=123456789", + "fuzz": "jazzer fuzz -i fuzz.js -i jszip -x Error corpus -- -runs=10000000 -print_final_stats=1 -use_value_profile=1 -max_len=600 -seed=123456789", "dryRun": "jazzer fuzz --sync -x Error -- -runs=100000 -seed=123456789" }, "devDependencies": { - "@jazzer.js/core": "file:../../packages/core" + "@jazzer.js/core": "file:../../../packages/core" } } diff --git a/examples/bug-detectors/prototype-pollution/config.js b/examples/bug-detectors/prototype-pollution/config.js new file mode 100644 index 000000000..dae63b2c9 --- /dev/null +++ b/examples/bug-detectors/prototype-pollution/config.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { + getBugDetectorConfiguration, + // eslint-disable-next-line @typescript-eslint/no-var-requires +} = require("../../../packages/bug-detectors"); + +getBugDetectorConfiguration("prototype-pollution") + ?.instrumentAssignmentsAndVariableDeclarations() + ?.addExcludedExactMatch("example"); diff --git a/examples/bug-detectors/prototype-pollution/fuzz.js b/examples/bug-detectors/prototype-pollution/fuzz.js new file mode 100644 index 000000000..7828ea77f --- /dev/null +++ b/examples/bug-detectors/prototype-pollution/fuzz.js @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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. + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const protobuf = require("protobufjs"); + +module.exports.fuzz = async function (data) { + try { + protobuf.parse(data.toString()); + } catch (e) { + // ignore + } +}; diff --git a/examples/bug-detectors/prototype-pollution/package.json b/examples/bug-detectors/prototype-pollution/package.json new file mode 100644 index 000000000..ca069a7d9 --- /dev/null +++ b/examples/bug-detectors/prototype-pollution/package.json @@ -0,0 +1,16 @@ +{ + "name": "prototype-pollution-example", + "version": "1.0.0", + "main": "fuzz.js", + "license": "ISC", + "dependencies": { + "protobufjs": "7.2.3" + }, + "scripts": { + "fuzz": "jazzer fuzz -i protobufjs -i fuzz -e nothing --timeout=60000 -x Error -- -runs=1000000 -print_final_stats=1 -use_value_profile=1 -rss_limit_mb=10000 -dict=userDict.txt", + "dryRun": "jazzer fuzz -i protobufjs -- -runs=100000000 -seed=123456789" + }, + "devDependencies": { + "@jazzer.js/core": "file:../../../packages/core" + } +} diff --git a/examples/bug-detectors/prototype-pollution/userDict.txt b/examples/bug-detectors/prototype-pollution/userDict.txt new file mode 100644 index 000000000..e1de5a1bf --- /dev/null +++ b/examples/bug-detectors/prototype-pollution/userDict.txt @@ -0,0 +1 @@ +"option (foo).constructor.prototype.test = true;" \ No newline at end of file diff --git a/packages/bug-detectors/DEVELOPMENT.md b/packages/bug-detectors/DEVELOPMENT.md new file mode 100644 index 000000000..f751849f7 --- /dev/null +++ b/packages/bug-detectors/DEVELOPMENT.md @@ -0,0 +1,158 @@ +# Bug Detector Development API + +Jazzer.js provides several tools for writing bug detectors. + +## Hooking library functions + +```typescript +const { + registerAfterHook, + registerBeforeHook, + registerReplaceHook +} = require("@jazzer.js/core"); +registerBeforeHook(target: string, pkg: string, async: boolean, hookFn: HookFn) +registerReplaceHook(target: string, pkg: string, async: boolean, hookFn: HookFn) +registerAfterHook(target: string, pkg: string, async: boolean, hookFn: HookFn) +``` + +## Adding before/after callbacks to the fuzz function + +```typescript +import { registerAfterEachCallback,registerBeforeEachCallback } from "@jazzer.js/core"; +registerAfterEachCallback(callback: () => void) +registerBeforeEachCallback(callback: () => void) +``` + +These functions can be used to add callback functions that will always be +executed before/after each fuzz test. + +## Adding instrumentation plugins + +```typescript +import { registerInstrumentationPlugin } from "@jazzer.js/core"; +registerInstrumentationPlugin(plugin: () => PluginTarget) +``` + +This function allows addition of instrumentation plugins to Jazzer.js. It +expects a function that returns a `PluginTarget` from `"@babel/core"`. For an +example of how to write an instrumentation plugin, see the +[Prototype Pollution](internal/prototype-pollution.ts) bug detector and the +[Jazzer.js instrumentation plugins](../instrumentor/plugins/). + +### Instrumentation guard + +To prevent endless loops because of instrumentation plugins adding statements +and expressions to the code and reinstrumenting them again, use the +`instrumentationGuard` to add values that should not be instrumented again: + +```typescript +import { instrumentationGuard } from "@jazzer.js/core"; +instrumentationGuard.add(tag: string, value: NodePath); +instrumentationGuard.has(tag: string, value: NodePath); +``` + +The `tag` is a string that identifies the type of the value. For example, the +prototype pollution bug detector uses the tags `'AssignmentExpression'` and +`'VariableDeclaration'` to avoid endless loops introduced by the visotors of +`AssignmentExpression` and `VariableDeclaration`, since both visitors introduce +a new variable declarations each that should not be instrumented by the other +visitor. + +Here are some examples of how the instrumentation guard is used in the prototype +pollution bug detector: + +```typescript +import { instrumentationGuard } from "@jazzer.js/core"; + +// Don't instrument if the node has been added to the guard before. +if (instrumentationGuard.has("AssignmentExpression", path.node)) { + return; +} + +// Add the node to the guard to prevent endless loops. +instrumentationGuard.add("AssignmentExpression", path.node); + +// Generate a new variable declaration. +const resultDeclarator = types.variableDeclarator( + result, + JSON.parse(JSON.stringify(path.node)), +); + +// Make sure the added variable declaration is not instrumented again. +instrumentationGuard.add("VariableDeclaration", resultDeclarator); +``` + +## Guiding the fuzzing process + +Import the fuzzer object from the `@jazzer.js/fuzzer` package: + +```typescript +import { fuzzer } from "@jazzer.js/fuzzer"; +``` + +There are several ways to guide the fuzzing process: + +- ```typescript + fuzzer.tracer.guideTowardsEquality(current: string, target: string, id: number) + ``` + + Instructs the fuzzer to guide its mutations towards making `current` equal to + `target`. + +- ```typescript + fuzzer.tracer.guideTowardsContainment(needle: string, haystack: string, id: number) + ``` + + Instructs the fuzzer to guide its mutations towards making `haystack` contain + `needle` as a substring. + +- ```typescript + fuzzer.tracer.exploreState(state: number, id: number) + ``` + + Instructs the fuzzer to attain as many possible values for the absolute value + of `state` as possible. + +## Dictionary based mutations + +Whenever adding the above guiding functions is not feasible, add values specific +to your bug detector to a dictionary. The dictionary is used by the fuzzer just +like any other mutator, which means that the fuzzer will occasionally take +values from the dictionary and replace parts of the whole input with it. The +syntax used by the dictionary is documented +[here](https://llvm.org/docs/LibFuzzer.html#dictionaries). + +- ```typescript + addDictionary(...libFuzzerDictionary: string[]) + ``` + +## Report findings + +To report a finding, use the `reportFinding` function from +`@jazzer.js/bug-detectors`: + +```typescript +reportFinding(findingMessage: string) +``` + +This function escapes the try/catch blocks and makes sure that the finding will +be reported by the fuzzer. + +## Allow users to configure bug detectors + +A bug detector can be made configurable by adding a configuration class to the +configuration map `bugDetectorConfigurations` in +`@jazzer.js/bug-detectors/configurations.ts`: + +```typescript +import { bugDetectorConfigurations } from "@jazzer.js/bug-detectors"; +// alternatively: +// import { bugDetectorConfigurations } from "../configurations"; +// if your bug detector is in the `internal` subfolder of the `bug-detectors` package +const config: = new (); +bugDetectorConfigurations.set("", config); +``` + +See the `PrototypePollutionConfig` in +[Prototype Pollution](internal/prototype-pollution.ts) bug detector for an +example. diff --git a/packages/bug-detectors/README.md b/packages/bug-detectors/README.md new file mode 100644 index 000000000..8e766a5c5 --- /dev/null +++ b/packages/bug-detectors/README.md @@ -0,0 +1,19 @@ +# @jazzer.js/bug-detectors + +The `@jazzer.js/bug-detectors` module is used by +[Jazzer.js](https://github.com/CodeIntelligenceTesting/jazzer.js#readme) to +detect and report bugs in JavaScript code. + +## Install + +Using npm: + +```shell +npm install --save-dev @jazzer.js/bug-detectors +``` + +## Documentation + +- Up-to-date + [information](https://github.com/CodeIntelligenceTesting/jazzer.js/blob/main/docs/fuzz-settings.md#bug-detectors) + about currently available bug detectors diff --git a/packages/bug-detectors/configuration.ts b/packages/bug-detectors/configuration.ts new file mode 100644 index 000000000..9a55262dc --- /dev/null +++ b/packages/bug-detectors/configuration.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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. + */ + +// User-facing API +export function getBugDetectorConfiguration(bugDetector: string): unknown { + return bugDetectorConfigurations.get(bugDetector); +} + +class BugDetectorConfigurations { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configurations = new Map(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(bugDetector: string, configuration: any): void { + this.configurations.set(bugDetector, configuration); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(bugDetector: string): any { + return this.configurations.get(bugDetector); + } +} + +export const bugDetectorConfigurations = new BugDetectorConfigurations(); diff --git a/packages/bug-detectors/index.ts b/packages/bug-detectors/index.ts index 4552bbe54..f7b05e75b 100644 --- a/packages/bug-detectors/index.ts +++ b/packages/bug-detectors/index.ts @@ -1,4 +1,5 @@ /* + * Copyright 2023 Code Intelligence GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,6 +15,11 @@ * limitations under the License. */ +import * as findings from "./findings"; +import * as fs from "fs"; +import * as path from "path"; +export { getBugDetectorConfiguration } from "./configuration"; + // Export user-facing API for writing custom bug detectors. export { reportFinding, @@ -22,33 +28,50 @@ export { Finding, } from "./findings"; -// Checks in the global options if the bug detector should be loaded. -function shouldDisableBugDetector( - disableBugDetectors: RegExp[], - bugDetectorName: string, -): boolean { - // pattern match for bugDetectorName in disableBugDetectors - for (const pattern of disableBugDetectors) { - if (pattern.test(bugDetectorName)) { - if (process.env.JAZZER_DEBUG) - console.log( - `Skip loading bug detector ${bugDetectorName} because it matches ${pattern}`, - ); - return true; - } - } - return false; +// Global API for bug detectors that can be used by instrumentation plugins. +export interface BugDetectors { + reportFinding: typeof findings.reportFinding; } -export async function loadBugDetectors( - disableBugDetectors: RegExp[], -): Promise { - // Dynamic imports require either absolute path, or a relative path with .js extension. - // This is ok, since our .ts files are compiled to .js files. - if (!shouldDisableBugDetector(disableBugDetectors, "command-injection")) { - await import("./internal/command-injection.js"); - } - if (!shouldDisableBugDetector(disableBugDetectors, "path-traversal")) { - await import("./internal/path-traversal.js"); - } +export const bugDetectors: BugDetectors = { + reportFinding: findings.reportFinding, +}; + +// Filters out disabled bug detectors and prepares all the others for dynamic import. +export function getFilteredBugDetectorPaths( + bugDetectorsDirectory: string, + disableBugDetectors: string[], +): string[] { + const disablePatterns = disableBugDetectors.map( + (pattern: string) => new RegExp(pattern), + ); + return ( + fs + .readdirSync(bugDetectorsDirectory) + // The compiled "internal" directory contains several files such as .js.map and .d.ts. + // We only need the .js files. + // Here we also filter out bug detectors that should be disabled. + .filter((bugDetectorPath) => { + if (!bugDetectorPath.endsWith(".js")) { + return false; + } + + // Dynamic imports need .js files. + 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), + ); + + if (shouldDisable) { + console.log( + `Skip loading bug detector "${bugDetectorName}" because of user-provided pattern.`, + ); + } + return !shouldDisable; + }) + // Get absolute paths for each bug detector. + .map((file) => path.join(bugDetectorsDirectory, file)) + ); } diff --git a/packages/bug-detectors/internal/command-injection.ts b/packages/bug-detectors/internal/command-injection.ts index ca609f931..5682ce969 100644 --- a/packages/bug-detectors/internal/command-injection.ts +++ b/packages/bug-detectors/internal/command-injection.ts @@ -15,7 +15,7 @@ */ import { reportFinding } from "../findings"; -import { guideTowardsContainment } from "@jazzer.js/fuzzer"; +import { fuzzer } from "@jazzer.js/fuzzer"; import { registerBeforeHook } from "@jazzer.js/hooking"; /** @@ -51,7 +51,7 @@ for (const functionName of functionNames) { `Command Injection in ${functionName}(): called with '${firstArgument}'`, ); } - guideTowardsContainment(firstArgument, goal, hookId); + fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); }; registerBeforeHook(functionName, moduleName, false, beforeHook); diff --git a/packages/bug-detectors/internal/path-traversal.ts b/packages/bug-detectors/internal/path-traversal.ts index aa5af367c..0b430328a 100644 --- a/packages/bug-detectors/internal/path-traversal.ts +++ b/packages/bug-detectors/internal/path-traversal.ts @@ -15,7 +15,7 @@ */ import { reportFinding } from "../findings"; -import { guideTowardsContainment } from "@jazzer.js/fuzzer"; +import { fuzzer } from "@jazzer.js/fuzzer"; import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking"; /** @@ -138,7 +138,7 @@ for (const module of modulesToHook) { `Path Traversal in ${functionName}(): called with '${firstArgument}'`, ); } - guideTowardsContainment(firstArgument, goal, hookId); + fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); }; registerBeforeHook(functionName, module.moduleName, false, beforeHook); @@ -184,10 +184,14 @@ for (const module of functionsWithTwoTargets) { ` and '${secondArgument}'`, ); } - guideTowardsContainment(firstArgument, goal, hookId); + fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); // We don't want to confuse the fuzzer guidance with the same hookId for both function arguments. // Therefore, we use an extra hookId for the second argument. - guideTowardsContainment(secondArgument, goal, extraHookId); + fuzzer.tracer.guideTowardsContainment( + secondArgument, + goal, + extraHookId, + ); }; }; diff --git a/packages/bug-detectors/internal/prototype-pollution.ts b/packages/bug-detectors/internal/prototype-pollution.ts new file mode 100644 index 000000000..091edc04d --- /dev/null +++ b/packages/bug-detectors/internal/prototype-pollution.ts @@ -0,0 +1,484 @@ +/* + * Copyright 2023 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 { AssignmentExpression, Identifier, Node } from "@babel/types"; +import { NodePath, PluginTarget, types } from "@babel/core"; +import { reportFinding } from "../findings"; +import { + addDictionary, + instrumentationGuard, + registerAfterEachCallback, + registerInstrumentationPlugin, +} from "@jazzer.js/hooking"; + +import { bugDetectorConfigurations } from "../configuration"; + +// Allow the user to configure this bug detector in the custom-hooks file (if any). +class PrototypePollutionConfig { + private _excludedExactMatches: string[] = []; + private _instrumentAssignments = false; + + /** + * Excludes one specific value of the `__proto__` property from being reported as a Prototype Pollution finding. + * This is only relevant when instrumenting assignment expressions and variable declarations. + * @param protoValue - stringified value of the `__proto__` for which no finding should be reported. + */ + addExcludedExactMatch(protoValue: string): PrototypePollutionConfig { + this._excludedExactMatches.push(protoValue); + return this; + } + + /** + * Enables instrumentation of assignment expressions and variable declarations. + * This is a costly operation that might find non-global Prototype Pollution. + * However, it also might result in false positives. Use `addExcludedExactMatch` + * to exclude specific values from being reported as Prototype Pollution findings. + * + * @example + * For the protobufjs library, you might add this to your custom-hooks file: + * ``` + * getBugDetectorConfiguration("prototype-pollution") + * ?.instrumentAssignmentsAndVariableDeclarations() + * ?.addExcludedExactMatch('{"methods":{}}') + * ?.addExcludedExactMatch{'{"fields":{}}'"); + * ``` + */ + instrumentAssignmentsAndVariableDeclarations(): PrototypePollutionConfig { + if (global.options.dryRun) { + console.error( + "ERROR: " + + "[Prototype Pollution Configuration] The configuration option " + + "instrumentAssignmentsAndVariableDeclarations() is not supported in dry run mode.\n" + + " Either disable dry-run mode or remove this option from custom hooks.\n" + + " Jazzer.js initial arguments:", + global.options, + ); + // We do not accept conflicting configuration options: abort. + process.exit(1); + } + this._instrumentAssignments = true; + return this; + } + + getExcludedExactMatches(): string[] { + return this._excludedExactMatches; + } + + getInstrumentAssignmentsAndVariableDeclarations(): boolean { + return this._instrumentAssignments; + } +} + +const config: PrototypePollutionConfig = new PrototypePollutionConfig(); + +// Add this bug detector's config to the global config map. +bugDetectorConfigurations.set("prototype-pollution", config); + +interface PrototypePollution { + getProtoSnapshot: typeof getProtoSnapshot; + detectPrototypePollution: typeof detectPrototypePollution; + protoSnapshotsEqual: typeof protoSnapshotsEqual; +} + +declare global { + // eslint-disable-next-line no-var + var PrototypePollution: PrototypePollution; +} + +// Make these functions available to instrumentation plugins and the user via the global object. +globalThis.PrototypePollution = { + getProtoSnapshot: getProtoSnapshot, + detectPrototypePollution: detectPrototypePollution, + protoSnapshotsEqual: protoSnapshotsEqual, +}; + +registerInstrumentationPlugin((): PluginTarget => { + function getIdentifierFromAssignmentExpression( + expr: AssignmentExpression, + ): Identifier | undefined { + if (types.isIdentifier(expr.left)) { + return expr.left; + } + return skipMemberExpressions(expr.left); + } + + function skipMemberExpressions(expr?: Node): Identifier | undefined { + if (types.isIdentifier(expr)) { + return expr; + } else if (types.isMemberExpression(expr) && expr.object) { + return skipMemberExpressions(expr.object); + } + } + + return { + // This does not help with the case where a prototype of an object is first assigned to a variable which is then + // used to pollute the prototype. However, as soon as a new object is created, the prototype is copied, and we will + // detect the pollution. We probably need to check the scope and track such assignments. + visitor: { + // Wraps assignment expression in a lambda, and checks if __proto__ of the identifier contains any non-function values. + // For example, the expression "a = 10;" will be transpiled to: + // "a = (() => { + // const _jazzerPP_result0 = a = 10; + // PrototypePollution.detectPrototypePollution(a, "a"); + // return _jazzerPP_result0; + // })();" + // This expression will be further instrumented by the regular Jazzer.js instrumentation plugins. + AssignmentExpression(path: NodePath) { + if ( + !config || + !config.getInstrumentAssignmentsAndVariableDeclarations() + ) { + return; + } + if (instrumentationGuard.has("AssignmentExpression", path.node)) { + return; + } + + // Get identifier of the variable being assigned to + const identifier = getIdentifierFromAssignmentExpression(path.node); + if (!identifier) { + return; + } + + // Wrap the whole expression in a lambda and check for __proto__ changes + const result = path.scope.generateUidIdentifier("jazzerPP_result"); + instrumentationGuard.add("AssignmentExpression", path.node); + // Copy path.node because it will be replaced by a lambda. + const resultDeclarator = types.variableDeclarator( + result, + JSON.parse(JSON.stringify(path.node)), + ); + instrumentationGuard.add("AssignmentExpression", resultDeclarator); + instrumentationGuard.add("VariableDeclaration", resultDeclarator); + + const newAssignment = types.callExpression( + types.arrowFunctionExpression( + [], + types.blockStatement([ + types.variableDeclaration("const", [resultDeclarator]), + // check for __proto__ changes + types.expressionStatement( + types.callExpression( + types.identifier( + "PrototypePollution.detectPrototypePollution", + ), + [identifier, types.stringLiteral("" + identifier.name)], + ), + ), + // return the original assignment + types.returnStatement(result), + ]), + ), + [], + ); + path.replaceWith(newAssignment); + }, + // Wraps variable declaration in a lambda, and checks if __proto__ of the identifier contains any non-function properties. + // For example: "const a = 10;" will be transpiled to: + // "const a = (() => { + // const _jazzerPP0 = 10; + // PrototypePollution.detectPrototypePollution(_jazzerPP0, "a"); + // return _jazzerPP0; + // })();" + // This expression will be further instrumented by the regular Jazzer.js instrumentation plugins. + VariableDeclarator(path: NodePath) { + if ( + !config || + !config.getInstrumentAssignmentsAndVariableDeclarations() + ) { + return; + } + // wrap the initializer in a lambda and check for __proto__ changes + if (path.node.init) { + if (instrumentationGuard.has("VariableDeclaration", path.node)) { + return; + } + + const newVariable = path.scope.generateUidIdentifier("jazzerPP"); + const markedDeclarator = types.variableDeclarator( + newVariable, + path.node.init, + ); + + const variable = path.node.id as types.Identifier; + instrumentationGuard.add("VariableDeclaration", markedDeclarator); + if (types.isAssignmentExpression(path.node.init)) + instrumentationGuard.add("AssignmentExpression", path.node.init); + + path.node.init = types.callExpression( + types.arrowFunctionExpression( + [], + types.blockStatement([ + types.variableDeclaration("const", [markedDeclarator]), + // check for __proto__ changes + types.expressionStatement( + types.callExpression( + types.identifier( + "PrototypePollution.detectPrototypePollution", + ), + [newVariable, types.stringLiteral("" + variable.name)], + ), + ), + // return the original initializer + types.returnStatement(newVariable), + ]), + ), + [], + ); + instrumentationGuard.add("VariableDeclaration", path.node.init); + } + }, + }, + }; +}); + +// These objects will be used to detect prototype pollution. +// Using global arrays for performance reasons. +const BASIC_OBJECTS = [ + {}, + [], + "", + 42, + true, + () => { + /**/ + }, +]; +// The names are used in the Findings to print nicer messages. +const BASIC_OBJECT_NAMES = [ + "Object", + "Array", + "String", + "Number", + "Boolean", + "Function", +]; + +type BasicProtoSnapshots = ProtoSnapshot[]; + +type ProtoSnapshot = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prototype: any; // Reference to the objects prototype object. + propertyNames: string[]; // Names of the properties of the object's prorotype (including function names). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + propertyValues: any[]; // Values of the properties of the object's prototype (including functions): +}; + +// Compute prototype snapshots of each selected basic object before any fuzz tests are run. +// These snapshots are used to detect prototype pollution after each fuzz test. +const BASIC_PROTO_SNAPSHOTS = computeBasicPrototypeSnapshots(); + +function computeBasicPrototypeSnapshots(): BasicProtoSnapshots { + return BASIC_OBJECTS.map(getProtoSnapshot); +} + +/** + * Make a snapshot of the object's prototype. + * The snapshot includes: + * 1) the reference to the object's prototype. + * 2) the names of the properties of the object's prototype (including function names). + * 3) the values of the properties of the object's prototype (including functions). + * @param obj - the object whose prototype we want to snapshot. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getProtoSnapshot(obj: any): ProtoSnapshot { + const prototype = Object.getPrototypeOf(obj); + const propertyNames = Object.getOwnPropertyNames(prototype); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const propertyValues: any[] = new Array(propertyNames.length); + try { + for (let i = 0; i < propertyNames.length; i++) { + propertyValues[i] = prototype[propertyNames[i]]; + } + } catch (e) { + // ignore + } + return { + prototype: prototype, + propertyNames: propertyNames, + propertyValues: propertyValues, + }; +} + +registerAfterEachCallback( + function detectPrototypePollutionOfBasicObjects(): void { + const currentProtoSnapshots = computeBasicPrototypeSnapshots(); + // Compare the current prototype snapshots of basic objects to the original ones. + for (let i = 0; i < BASIC_PROTO_SNAPSHOTS.length; i++) { + if (!currentProtoSnapshots[i]) { + reportFinding( + `Prototype Pollution: Prototype of ${BASIC_OBJECT_NAMES[i]} changed.`, + ); + return; + } + const equalityResult = protoSnapshotsEqual( + BASIC_PROTO_SNAPSHOTS[i], + currentProtoSnapshots[i], + ); + if (equalityResult) { + reportFinding( + `Prototype Pollution: Prototype of ${BASIC_OBJECT_NAMES[i]} changed. ${equalityResult}`, + ); + return; + } + } + }, +); + +// There are two main ways to pollute a prototype of an object: +// 1. Changing a prototype's property using __proto__ +// 2. Changing a prototype's property using constructor.prototype +// This dictionary adds these strings to the fuzzer dictionary. +// Adding strings targeted at specific protocols (XML, HTTP, protobuf, etc.) will reduce the performance of the fuzzer, +// because it will try strings from the wrong protocol. Therefore, it is advised to add protocol-specific strings +// to the user dictionary for each fuzz test individually. +addDictionary( + '"__proto__"', + '"constructor"', + '"prototype"', + '"constructor.prototype"', +); + +/** + * Checks if the object's proto contains any non-function properties. Function properties are ignored. + * @param obj The object to check. + * @param identifier The identifier of the object (used for printing a useful finding message). + * @param report Whether to report a finding if the object is a prototype pollution object. + */ +function detectPrototypePollution( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj: any, + identifier?: string, + report = true, +) { + while (obj !== undefined && obj !== null) { + try { + // JSON.stringify will ignore function properties. + const protoValue = JSON.stringify(Object.getPrototypeOf(obj)); + if ( + protoValue && + !( + protoValue === "null" || + protoValue === "{}" || + protoValue === "[]" || + protoValue === '""' || + protoValue === "false" || + protoValue === "true" || + protoValue === "0" || + // User-defined pollution strings are whitelisted here. + config?.getExcludedExactMatches()?.includes(protoValue) + ) + ) { + let message; + if (identifier) { + message = `Prototype Pollution: ${identifier}.__proto__ value is ${protoValue}`; + } else { + message = `Prototype Pollution: __proto__ value is ${protoValue}`; + } + if (report) { + reportFinding(message); + } + // If prototype pollution is detected, always stop analyzing the prototype chain. + return; + } + } catch (e) { + // Ignored. + } + // Get the same data from the object's prototype. + obj = Object.getPrototypeOf(obj); + } +} + +/** + * Checks if two prototype snapshots are equal. If they don't, throw a finding with a meaningful message. + * @param snapshot1 The first prototype snapshot. + * @param snapshot2 The second prototype snapshot. + */ +// This is used for basic objects, such as {}, [], Function, number, string. +function protoSnapshotsEqual( + snapshot1: ProtoSnapshot, + snapshot2: ProtoSnapshot, +): string | undefined { + if (snapshot1.prototype !== snapshot2.prototype) { + return `Different [[Prototype]]: ${snapshot1.prototype} vs ${snapshot2.prototype}`; + } + + if (snapshot1.propertyNames.length !== snapshot2.propertyNames.length) { + const printNamesAndValues = (names: string[], values: string[]): string => { + const namesAndValues = names + .map((name, index) => `'${name}': ${values[index]}`) + .join(", "); + return "{ " + namesAndValues + " }"; + }; + // The number of properties has changed: assemble a meaningful message to + // the user stating which properties are missing/extra for each prototype object. + // Get the complement of propertyNames. + const complement1 = snapshot1.propertyNames.filter( + (x) => !snapshot2.propertyNames.includes(x), + ); + const complement2 = snapshot2.propertyNames.filter( + (x) => !snapshot1.propertyNames.includes(x), + ); + // Get corresponding snapshot1.propertyValues + const complement1Values = complement1.map( + (name) => snapshot1.propertyValues[snapshot1.propertyNames.indexOf(name)], + ); + const complement2Values = complement2.map( + (name) => snapshot2.propertyValues[snapshot2.propertyNames.indexOf(name)], + ); + let message = ""; + if (complement1.length > 0) { + message += + "Additional properties in object0: " + + printNamesAndValues(complement1, complement1Values); + } + if (complement2.length > 0) { + message += + "Additional properties in object1: " + + printNamesAndValues(complement2, complement2Values); + } + return message; + } + + // Lengths are the same, now we can compare the property names. + for ( + let propertyId = 0; + propertyId < snapshot1.propertyNames.length; + propertyId++ + ) { + if ( + snapshot1.propertyNames[propertyId] !== + snapshot2.propertyNames[propertyId] + ) { + return `Different or rearranged property names: ${snapshot1.propertyNames[propertyId]} vs. ${snapshot2.propertyNames[propertyId]}`; + } + } + + // Property names are the same, now we can compare the values. + for ( + let propertyId = 0; + propertyId < snapshot1.propertyValues.length; + propertyId++ + ) { + if ( + snapshot1.propertyValues[propertyId] !== + snapshot2.propertyValues[propertyId] + ) { + return `Different properties: ${snapshot1.propertyNames[propertyId]}: ${snapshot1.propertyValues[propertyId]} vs. +${snapshot2.propertyNames[propertyId]}: ${snapshot2.propertyValues[propertyId]}`; + } + } +} diff --git a/packages/core/cli.ts b/packages/core/cli.ts index a15f49a61..adb4dbea2 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -206,6 +206,8 @@ yargs(process.argv.slice(2)) }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (args: any) => { + // Set verbose mode environment variable. If the environment variable is + // set, the verbose mode flag is ignored. if (args.verbose) { process.env.JAZZER_DEBUG = "1"; } diff --git a/packages/core/core.ts b/packages/core/core.ts index c3bc62f1a..500e8c84d 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -26,8 +26,8 @@ import * as hooking from "@jazzer.js/hooking"; import { clearFirstFinding, Finding, + getFilteredBugDetectorPaths, getFirstFinding, - loadBugDetectors, } from "@jazzer.js/bug-detectors"; import { FileSyncIdStrategy, @@ -63,6 +63,8 @@ export interface Options { coverageDirectory: string; coverageReporters: reports.ReportType[]; disableBugDetectors: string[]; + mode?: "fuzzing" | "regression"; + verbose?: boolean; } interface FuzzModule { @@ -77,8 +79,9 @@ declare global { var options: Options; } -export async function initFuzzing(options: Options) { - registerGlobals(); +export async function initFuzzing(options: Options): Promise { + registerGlobals(options); + registerInstrumentor( new Instrumentor( options.includes, @@ -91,18 +94,29 @@ export async function initFuzzing(options: Options) { : new MemorySyncIdStrategy(), ), ); - // Loads custom hook files and adds them to the hook manager. - await Promise.all(options.customHooks.map(ensureFilepath).map(importModule)); - // Load built-in bug detectors. Some of them might register hooks with the hook manager. - // Each bug detector is written in its own file, and theoretically could be loaded in the same way as custom hooks - // above. However, the path the bug detectors must be the compiled path. For this reason we decided to load them - // using this function, which loads each bug detector relative to the bug-detectors directory. E.g., in Jazzer - // (without the .js) there is no distinction between custom hooks and bug detectors. - await loadBugDetectors( - options.disableBugDetectors.map((pattern: string) => new RegExp(pattern)), + // Dynamic import works only with javascript files, so we have to manually specify the directory with the + // transpiled bug detector files. + const possibleBugDetectorFiles = getFilteredBugDetectorPaths( + path.join(__dirname, "../../bug-detectors/dist/internal"), + options.disableBugDetectors, + ); + + if (process.env.JAZZER_DEBUG) { + console.log( + "INFO: [BugDetector] Loading bug detectors: \n " + + possibleBugDetectorFiles.join("\n "), + ); + } + + // Load bug detectors before loading custom hooks because some bug detectors can be configured in the + // custom hooks file. + await Promise.all( + possibleBugDetectorFiles.map(ensureFilepath).map(importModule), ); + await Promise.all(options.customHooks.map(ensureFilepath).map(importModule)); + // Built-in functions cannot be hooked by the instrumentor, so we manually hook them here. await hookBuiltInFunctions(hooking.hookManager); } @@ -125,9 +139,10 @@ async function hookBuiltInFunctions(hookManager: hooking.HookManager) { } } -export function registerGlobals() { +export function registerGlobals(options: Options) { globalThis.Fuzzer = fuzzer.fuzzer; globalThis.HookManager = hooking.hookManager; + globalThis.options = options; } export async function startFuzzing(options: Options) { @@ -185,7 +200,13 @@ export async function startFuzzingNoInit( const fuzzerOptions = buildFuzzerOptions(options); logInfoAboutFuzzerOptions(fuzzerOptions); - + // in verbose mode print the configuration + if (process.env.JAZZER_DEBUG) { + console.debug("DEBUG: [core] Jazzer.js initial arguments: "); + console.debug(options); + console.debug("DEBUG: [core] Jazzer.js actually used fuzzer arguments: "); + console.debug(fuzzerOptions); + } if (options.sync) { return Promise.resolve().then(() => Fuzzer.startFuzzing( @@ -395,7 +416,7 @@ function buildFuzzerOptions(options: Options): string[] { } let opts = options.fuzzerOptions; - if (options.dryRun) { + if (options.mode === "regression") { // the last provided option takes precedence opts = opts.concat("-runs=0"); } @@ -410,6 +431,61 @@ function buildFuzzerOptions(options: Options): string[] { // with the Node.js signal handling. opts = opts.concat("-handle_int=0", "-handle_term=0"); + // Dictionary handling. This diverges from the libfuzzer behavior, which allows only one dictionary (the last one). + // We merge all dictionaries into one and pass that to libfuzzer. + let shouldUseDictionaries = false; + const mergedDictionary = `.JazzerJs-merged-dictionaries`; + let dictionary = ""; + + // Extract dictionaries from bug detectors. + for (const dict of hooking.hookManager.getDictionaries()) { + // Make an empty dictionary file. + if (!shouldUseDictionaries) { + shouldUseDictionaries = true; + } + // Append the contents of dict to the .jazzer-merged-dictionaries file. + dictionary = dictionary.concat(dict); + } + + // Merge all dictionaries into one: .jazzer-all-dictionaries. + for (const option of options.fuzzerOptions) { + if (option.startsWith("-dict=")) { + const dict = option.substring(6); + // if the dictionary is the same as the merged dictionary, skip it. + if (dict === mergedDictionary) { + continue; + } + // Make an empty dictionary file. + if (!shouldUseDictionaries) { + shouldUseDictionaries = true; + } + + // Preserve the file name in a comment before merging dictionary contents. + dictionary = dictionary.concat(`\n# ${dict}:\n`); + dictionary = dictionary.concat(fs.readFileSync(dict).toString()); + // Drop the dictionary from the list of options. + opts = opts.filter((o) => o !== option); + } + } + + if (shouldUseDictionaries) { + // Add a comment to the top of the dictionary file. + dictionary = + "# This file was automatically generated. Do not edit.\n" + dictionary; + // Check if the merged dictionary already exists and has the same contents. + if (fs.existsSync(mergedDictionary)) { + const existingDictionary = fs.readFileSync(mergedDictionary).toString(); + // Overwrite only if the dictionary contents differ. + if (existingDictionary !== dictionary) { + fs.writeFileSync(mergedDictionary, dictionary); + } + } else { + // Otherwise, create the file. + fs.writeFileSync(mergedDictionary, dictionary); + } + opts = opts.concat(`-dict=${mergedDictionary}`); + } + return [prepareLibFuzzerArg0(opts), ...opts]; } @@ -441,6 +517,7 @@ export function wrapFuzzFunctionForBugDetection( let fuzzTargetError: unknown; let result: void | Promise = undefined; try { + hooking.hookManager.runBeforeEachCallbacks(); result = (originalFuzzFn as fuzzer.FuzzTargetAsyncOrValue)(data); // Explicitly set promise handlers to process findings, but still return // the fuzz target result directly, so that sync execution is still @@ -448,6 +525,7 @@ export function wrapFuzzFunctionForBugDetection( if (result instanceof Promise) { result = result.then( (result) => { + hooking.hookManager.runAfterEachCallbacks(); return throwIfError() ?? result; }, (reason) => { @@ -458,6 +536,10 @@ export function wrapFuzzFunctionForBugDetection( } catch (e) { fuzzTargetError = e; } + // Promises are handled above, so we only need to handle sync results here. + if (!(result instanceof Promise)) { + hooking.hookManager.runAfterEachCallbacks(); + } return throwIfError(fuzzTargetError) ?? result; }; } else { @@ -465,18 +547,23 @@ export function wrapFuzzFunctionForBugDetection( data: Buffer, done: (err?: Error) => void, ): void | Promise => { + let result: void | Promise = undefined; try { + hooking.hookManager.runBeforeEachCallbacks(); // Return result of fuzz target to enable sanity checks in C++ part. - return originalFuzzFn(data, (err?: Error) => { + result = originalFuzzFn(data, (err?: Error) => { const finding = getFirstFinding(); if (finding !== undefined) { clearFirstFinding(); } + hooking.hookManager.runAfterEachCallbacks(); done(finding ?? err); }); } catch (e) { + hooking.hookManager.runAfterEachCallbacks(); throwIfError(e); } + return result; }; } } diff --git a/packages/core/jazzer.ts b/packages/core/jazzer.ts index a51d38425..0c5fcc722 100644 --- a/packages/core/jazzer.ts +++ b/packages/core/jazzer.ts @@ -15,20 +15,16 @@ * limitations under the License. */ -import { - exploreState, - guideTowardsContainment, - guideTowardsEquality, -} from "@jazzer.js/fuzzer"; +import { fuzzer } from "@jazzer.js/fuzzer"; export interface Jazzer { - guideTowardsEquality: typeof guideTowardsEquality; - guideTowardsContainment: typeof guideTowardsContainment; - exploreState: typeof exploreState; + guideTowardsEquality: typeof fuzzer.tracer.guideTowardsEquality; + guideTowardsContainment: typeof fuzzer.tracer.guideTowardsContainment; + exploreState: typeof fuzzer.tracer.exploreState; } export const jazzer: Jazzer = { - guideTowardsEquality, - guideTowardsContainment, - exploreState, + guideTowardsEquality: fuzzer.tracer.guideTowardsEquality, + guideTowardsContainment: fuzzer.tracer.guideTowardsContainment, + exploreState: fuzzer.tracer.exploreState, }; diff --git a/packages/fuzzer/fuzzer.ts b/packages/fuzzer/fuzzer.ts index bde7804d6..0cda36f50 100644 --- a/packages/fuzzer/fuzzer.ts +++ b/packages/fuzzer/fuzzer.ts @@ -42,62 +42,5 @@ export const fuzzer: Fuzzer = { stopFuzzing: addon.stopFuzzing, }; -/** - * Instructs the fuzzer to guide its mutations towards making `current` equal to `target` - * - * If the relation between the raw fuzzer input and the value of `current` is relatively - * complex, running the fuzzer with the argument `-use_value_profile=1` may be necessary to - * achieve equality. - * - * @param current a non-constant string observed during fuzz target execution - * @param target a string that `current` should become equal to, but currently isn't - * @param id a (probabilistically) unique identifier for this particular compare hint - */ -export function guideTowardsEquality( - current: string, - target: string, - id: number, -) { - tracer.traceUnequalStrings(id, current, target); -} - -/** - * Instructs the fuzzer to guide its mutations towards making `haystack` contain `needle` as a substring. - * - * If the relation between the raw fuzzer input and the value of `haystack` is relatively - * complex, running the fuzzer with the argument `-use_value_profile=1` may be necessary to - * satisfy the substring check. - * - * @param needle a string that should be contained in `haystack` as a substring, but - * currently isn't - * @param haystack a non-constant string observed during fuzz target execution - * @param id a (probabilistically) unique identifier for this particular compare hint - */ -export function guideTowardsContainment( - needle: string, - haystack: string, - id: number, -) { - tracer.traceStringContainment(id, needle, haystack); -} - -/** - * Instructs the fuzzer to attain as many possible values for the absolute value of `state` - * as possible. - * - * Call this function from a fuzz target or a hook to help the fuzzer track partial progress - * (e.g. by passing the length of a common prefix of two lists that should become equal) or - * explore different values of state that is not directly related to code coverage. - * - * Note: This hint only takes effect if the fuzzer is run with the argument - * `-use_value_profile=1`. - * - * @param state a numeric encoding of a state that should be varied by the fuzzer - * @param id a (probabilistically) unique identifier for this particular state hint - */ -export function exploreState(state: number, id: number) { - tracer.tracePcIndir(id, state); -} - export type { CoverageTracker } from "./coverage"; export type { Tracer } from "./trace"; diff --git a/packages/fuzzer/trace.ts b/packages/fuzzer/trace.ts index f51467279..5b3e0ceb3 100644 --- a/packages/fuzzer/trace.ts +++ b/packages/fuzzer/trace.ts @@ -129,6 +129,9 @@ export interface Tracer { traceNumberCmp: typeof traceNumberCmp; traceAndReturn: typeof traceAndReturn; tracePcIndir: typeof addon.tracePcIndir; + guideTowardsEquality: typeof guideTowardsEquality; + guideTowardsContainment: typeof guideTowardsContainment; + exploreState: typeof exploreState; } export const tracer: Tracer = { @@ -138,4 +141,68 @@ export const tracer: Tracer = { traceNumberCmp, traceAndReturn, tracePcIndir: addon.tracePcIndir, + guideTowardsEquality: guideTowardsEquality, + guideTowardsContainment: guideTowardsContainment, + exploreState: exploreState, }; + +/** + * Instructs the fuzzer to guide its mutations towards making `current` equal to `target` + * + * If the relation between the raw fuzzer input and the value of `current` is relatively + * complex, running the fuzzer with the argument `-use_value_profile=1` may be necessary to + * achieve equality. + * + * @param current a non-constant string observed during fuzz target execution + * @param target a string that `current` should become equal to, but currently isn't + * @param id a (probabilistically) unique identifier for this particular compare hint + */ +export function guideTowardsEquality( + current: string, + target: string, + id: number, +) { + tracer.traceUnequalStrings(id, current, target); +} + +/** + * Instructs the fuzzer to guide its mutations towards making `haystack` contain `needle` as a substring. + * + * If the relation between the raw fuzzer input and the value of `haystack` is relatively + * complex, running the fuzzer with the argument `-use_value_profile=1` may be necessary to + * satisfy the substring check. + * + * @param needle a string that should be contained in `haystack` as a substring, but + * currently isn't + * @param haystack a non-constant string observed during fuzz target execution + * @param id a (probabilistically) unique identifier for this particular compare hint + */ +export function guideTowardsContainment( + needle: string, + haystack: string, + id: number, +) { + // needle and haystack should be both strings + if (typeof needle !== "string" || typeof haystack !== "string") { + return; + } + tracer.traceStringContainment(id, needle, haystack); +} + +/** + * Instructs the fuzzer to attain as many possible values for the absolute value of `state` + * as possible. + * + * Call this function from a fuzz target or a hook to help the fuzzer track partial progress + * (e.g. by passing the length of a common prefix of two lists that should become equal) or + * explore different values of state that is not directly related to code coverage. + * + * Note: This hint only takes effect if the fuzzer is run with the argument + * `-use_value_profile=1`. + * + * @param state a numeric encoding of a state that should be varied by the fuzzer + * @param id a (probabilistically) unique identifier for this particular state hint + */ +export function exploreState(state: number, id: number) { + tracer.tracePcIndir(id, state); +} diff --git a/packages/hooking/manager.ts b/packages/hooking/manager.ts index 06398b4f0..82213cbe9 100644 --- a/packages/hooking/manager.ts +++ b/packages/hooking/manager.ts @@ -24,6 +24,7 @@ import { logHooks, hookTracker, } from "./hook"; +import { PluginTarget } from "@babel/core"; export class MatchingHooksResult { public beforeHooks: Hook[] = []; @@ -101,6 +102,10 @@ export class MatchingHooksResult { export class HookManager { private _hooks: Hook[] = []; + private afterEachCallbacks: Array = []; + private beforeEachCallbacks: Array = []; + private dictionaries: Array = []; + private instrumentationPlugins: Array<() => PluginTarget> = []; registerHook( hookType: HookType, @@ -180,6 +185,46 @@ export class HookManager { ); } } + + registerAfterEachCallback(callback: Thunk) { + this.afterEachCallbacks.push(callback); + } + + registerBeforeEachCallback(callback: Thunk) { + this.beforeEachCallbacks.push(callback); + } + + addDictionary(libFuzzerDictionary: string[]) { + this.dictionaries.push(this.compileFuzzerDictionary(libFuzzerDictionary)); + } + + registerInstrumentationPlugin(plugin: () => PluginTarget) { + this.instrumentationPlugins.push(plugin); + } + + getDictionaries() { + return this.dictionaries; + } + + getInstrumentationPlugins() { + return this.instrumentationPlugins; + } + + runAfterEachCallbacks() { + for (const afterEachCallback of this.afterEachCallbacks) { + afterEachCallback(); + } + } + + runBeforeEachCallbacks() { + for (const beforeEachCallback of this.beforeEachCallbacks) { + beforeEachCallback(); + } + } + + private compileFuzzerDictionary(lines: string[]): string { + return lines.join("\n"); + } } export function callSiteId(...additionalArguments: unknown[]): number { @@ -198,6 +243,8 @@ export function callSiteId(...additionalArguments: unknown[]): number { return hash; } +type Thunk = () => void; + export const hookManager = new HookManager(); // convenience functions to register hooks export function registerBeforeHook( @@ -227,6 +274,22 @@ export function registerAfterHook( hookManager.registerHook(HookType.After, target, pkg, async, hookFn); } +export function registerAfterEachCallback(callback: Thunk) { + hookManager.registerAfterEachCallback(callback); +} + +export function registerBeforeEachCallback(callback: Thunk) { + hookManager.registerBeforeEachCallback(callback); +} + +export function addDictionary(...libFuzzerDictionary: string[]) { + hookManager.addDictionary(libFuzzerDictionary); +} + +export function registerInstrumentationPlugin(plugin: () => PluginTarget) { + hookManager.registerInstrumentationPlugin(plugin); +} + /** * Replaces a built-in function with a custom implementation while preserving * the original function for potential use within the replacement function. @@ -255,3 +318,35 @@ export async function hookBuiltInFunction(hook: Hook): Promise { logHooks([hook]); hookTracker.addApplied(hook.pkg, hook.target); } + +// Keep track of statements and expressions that should not be instrumented. +// This is necessary to avoid infinite recursion when instrumenting code. +class InstrumentationGuard { + private map: Map> = new Map(); + + /** + * Add a tag and a value to the guard. This can be used to look up if the value. + * The value will be stringified internally before being added to the guard. + * @example instrumentationGuard.add("AssignmentExpression", node.left); + */ + add(tag: string, value: unknown) { + if (!this.map.has(tag)) { + this.map.set(tag, new Set()); + } + this.map.get(tag)?.add(JSON.stringify(value)); + } + + /** + * Check if a value with a given tag exists in the guard. The value will be stringified internally before being checked. + * @example instrumentationGuard.has("AssignmentExpression", node.object); + */ + has(expression: string, value: unknown): boolean { + return ( + (this.map.has(expression) && + this.map.get(expression)?.has(JSON.stringify(value))) ?? + false + ); + } +} + +export const instrumentationGuard = new InstrumentationGuard(); diff --git a/packages/instrumentor/instrument.ts b/packages/instrumentor/instrument.ts index c43ead52b..a17d0c58e 100644 --- a/packages/instrumentor/instrument.ts +++ b/packages/instrumentor/instrument.ts @@ -73,7 +73,11 @@ export class Instrumentor { const shouldInstrumentFile = this.shouldInstrumentForFuzzing(filename); if (shouldInstrumentFile) { - transformations.push(codeCoverage(this.idStrategy), compareHooks); + transformations.push( + ...hookManager.getInstrumentationPlugins(), + codeCoverage(this.idStrategy), + compareHooks, + ); } if (hookManager.hasFunctionsToHook(filename)) { diff --git a/packages/jest-runner/config.ts b/packages/jest-runner/config.ts index 00743b9c2..7c3407f10 100644 --- a/packages/jest-runner/config.ts +++ b/packages/jest-runner/config.ts @@ -32,6 +32,8 @@ export const defaultOptions: Options = { coverageDirectory: "coverage", coverageReporters: ["json", "text", "lcov", "clover"], // default Jest reporters disableBugDetectors: [], + mode: "regression", + verbose: false, }; // Looks up Jazzer.js options via the `jazzer-runner` configuration from @@ -56,7 +58,11 @@ export function loadConfig(optionsKey = "jazzerjs"): Options { // Switch to fuzzing mode if environment variable `JAZZER_FUZZ` is set. if (process.env.JAZZER_FUZZ) { - config.dryRun = false; + config.mode = "fuzzing"; + } + + if (config.verbose) { + process.env.JAZZER_DEBUG = "1"; } return config; diff --git a/packages/jest-runner/fuzz.ts b/packages/jest-runner/fuzz.ts index ad07e1375..e070b2bc9 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -83,10 +83,12 @@ export const fuzz: FuzzTest = (name, fn, timeout) => { const wrappedFn = wrapFuzzFunctionForBugDetection(fn); - if (fuzzingConfig.dryRun) { + if (fuzzingConfig.mode === "regression") { runInRegressionMode(name, wrappedFn, corpus, timeout); - } else { + } else if (fuzzingConfig.mode === "fuzzing") { runInFuzzingMode(name, wrappedFn, corpus, fuzzingConfig); + } else { + throw new Error(`Unknown mode ${fuzzingConfig.mode}`); } }; diff --git a/packages/jest-runner/index.ts b/packages/jest-runner/index.ts index 1e5f8d851..524578f2e 100644 --- a/packages/jest-runner/index.ts +++ b/packages/jest-runner/index.ts @@ -18,7 +18,7 @@ import { loadConfig } from "./config"; import { cleanupJestRunnerStack } from "./errorUtils"; import { FuzzTest } from "./fuzz"; import { JazzerWorker } from "./worker"; -import { registerGlobals, initFuzzing } from "@jazzer.js/core"; +import { initFuzzing } from "@jazzer.js/core"; import { CallbackTestRunner, OnTestFailure, @@ -39,7 +39,6 @@ class FuzzRunner extends CallbackTestRunner { super(globalConfig, context); this.shouldCollectCoverage = globalConfig.collectCoverage; this.coverageReporters = globalConfig.coverageReporters; - registerGlobals(); } async runTests( diff --git a/tests/bug-detectors/general/package.json b/tests/bug-detectors/general/package.json index 17e7652a0..229ce0efc 100644 --- a/tests/bug-detectors/general/package.json +++ b/tests/bug-detectors/general/package.json @@ -1,7 +1,7 @@ { - "name": "jazzerjs-command-injection-tests", + "name": "jazzerjs-general-bug-detector-tests", "version": "1.0.0", - "description": "Tests for the command injection bug detector", + "description": "Tests for checking the general functionality of bug detectors", "scripts": { "test": "jest", "fuzz": "JAZZER_FUZZ=1 jest" diff --git a/tests/bug-detectors/package.json b/tests/bug-detectors/package.json index 164ad4a75..1caf50616 100644 --- a/tests/bug-detectors/package.json +++ b/tests/bug-detectors/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "description": "Tests for Jazzer's bug detectors", "scripts": { - "fuzz": "jest --verbose" + "fuzz": "jest --verbose", + "test": "jest --verbose" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/bug-detectors/prototype-pollution.test.js b/tests/bug-detectors/prototype-pollution.test.js new file mode 100644 index 000000000..9ebb4769f --- /dev/null +++ b/tests/bug-detectors/prototype-pollution.test.js @@ -0,0 +1,447 @@ +/* + * Copyright 2023 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 { readFileSync } = require("fs"); +const { FuzzTestBuilder, FuzzingExitCode } = require("../helpers.js"); +const { JestRegressionExitCode } = require("../helpers"); + +describe("Prototype Pollution", () => { + const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); + + it("{} Pollution", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("BaseObjectPollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("{} Pollution using square braces", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("BaseObjectPollutionWithSquareBraces") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("[] Pollution", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("ArrayObjectPollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("Function Pollution", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("FunctionObjectPollution") + .sync(true) + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain( + "Prototype Pollution: Prototype of Function changed", + ); + }); + + it('"" Pollution', () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("StringObjectPollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain( + "Prototype Pollution: Prototype of String changed", + ); + }); + + it("0 Pollution", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("NumberObjectPollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain( + "Prototype Pollution: Prototype of Number changed", + ); + }); + + it("Boolean Pollution", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("BooleanObjectPollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain( + "Prototype Pollution: Prototype of Boolean changed", + ); + }); + + it("Pollute using constructor.prototype", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("ConstructorPrototype") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain( + "Prototype Pollution: a.__proto__ value is ", + ); + }); + + it("Test instrumentation and local pollution with single assignment", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("LocalPrototypePollution") + .sync(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution: a.__proto__"); + }); + + it("Test no instrumentation and polluting __proto__ of a class", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("PollutingAClass") + .sync(true) + .verbose(true) + .build(); + fuzzTest.execute(); + }); + + it("Instrumentation on and polluting __proto__ of a class", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("PollutingAClass") + .sync(true) + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("Instrumentation on with excluded exact match", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all-exclude-one.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("PollutingAClass") + .sync(true) + .verbose(true) + .build(); + fuzzTest.execute(); + }); + + it("Detect changed toString() of {}", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("ChangedToString") + .sync(true) + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("Detect deleted toString() of {}", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .fuzzEntryPoint("DeletedToString") + .sync(true) + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + it("Two-stage prototype pollution with object creation", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("TwoStagePollutionWithObjectCreation") + .sync(true) + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(FuzzingExitCode); + expect(fuzzTest.stdout).toContain("Prototype Pollution"); + }); + + // Challenge to the future developer: make this test pass! + // it("Two-stage prototype pollution using instrumentation", () => { + // const fuzzTest = new FuzzTestBuilder() + // .customHooks([ + // path.join(bugDetectorDirectory, "instrument-all.config.js"), + // ]) + // .dir(bugDetectorDirectory) + // .fuzzEntryPoint("TwoStagePollution") + // .sync(true) + // .verbose(true) + // .build(); + // expect(() => { + // fuzzTest.execute(); + // }).toThrowError(FuzzingExitCode); + // expect(fuzzTest.stdout).toContain("Prototype Pollution"); + // }); +}); + +describe("Prototype Pollution Dictionary Tests", () => { + const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); + + it("One user dictionary", () => { + const fuzzTest = new FuzzTestBuilder() + .dictionaries(["01-UserDictionary.txt"]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("DictionaryTest") + .sync(true) + .verbose(true) + .build(); + fuzzTest.execute(); + // Check if the contents of the user dictionary are in the merged dictionary + const userDictionary = readFileSync( + path.join(bugDetectorDirectory, "01-UserDictionary.txt"), + ); + const mergedDictionary = readFileSync( + path.join(bugDetectorDirectory, ".JazzerJs-merged-dictionaries"), + ); + expect(mergedDictionary.toString()).toContain(userDictionary.toString()); + }); + + it("Two user dictionaries", () => { + const fuzzTest = new FuzzTestBuilder() + .dictionaries(["01-UserDictionary.txt", "02-UserDictionary.txt"]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("DictionaryTest") + .sync(true) + .verbose(true) + .build(); + fuzzTest.execute(); + // Check if the contents of the user dictionaries are in the merged dictionary + const userDictionary1 = + readFileSync( + path.join(bugDetectorDirectory, "01-UserDictionary.txt"), + ).toString() + "\n"; + const userDictionary2 = readFileSync( + path.join(bugDetectorDirectory, "01-UserDictionary.txt"), + ).toString(); + const mergedDictionary = readFileSync( + path.join(bugDetectorDirectory, ".JazzerJs-merged-dictionaries"), + ).toString(); + expect(mergedDictionary).toContain(userDictionary1); + expect(mergedDictionary).toContain(userDictionary2); + }); +}); + +describe("Prototype Pollution Jest tests", () => { + const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); + + it("PP pollution of Object", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .dryRun(true) + .jestTestFile("tests.fuzz.js") + .jestTestName("Pollution of Object") + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain( + "Prototype Pollution: Prototype of Object changed", + ); + }); + + it("Instrumentation of assignment expressions", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("Assignments") + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain( + "Prototype Pollution: a.__proto__ value is", + ); + }); + + it("Instrumentation of variable declarations", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .dryRun(false) + .jestTestFile("tests.fuzz.js") + .jestTestName("Variable declarations") + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError(JestRegressionExitCode); + expect(fuzzTest.stderr).toContain( + "Prototype Pollution: a.__proto__ value is", + ); + }); + + it("Fuzzing mode pollution of Object", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(bugDetectorDirectory) + .dryRun(true) + .jestRunInFuzzingMode(true) + .jestTestFile("tests.fuzz.js") + .jestTestName("Fuzzing mode pollution of Object") + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrowError( + process.platform === "win32" ? JestRegressionExitCode : FuzzingExitCode, + ); + expect(fuzzTest.stderr).toContain( + "Prototype Pollution: Prototype of Object changed", + ); + }); + + it("Fuzzing mode instrumentation off - variable declaration", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .dryRun(true) + .jestRunInFuzzingMode(true) + .jestTestFile("tests.fuzz.js") + .jestTestName("Variable declarations") + .verbose(true) + .build(); + expect(() => { + fuzzTest.execute(); + }).toThrow(); + expect(fuzzTest.stderr).toContain("[Prototype Pollution Configuration]"); + }); +}); + +describe("Prototype Pollution instrumentation correctness tests", () => { + const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); + const fuzzFile = "instrumentation-correctness-tests"; + + it("Basic assignment", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("OnePlusOne") + .fuzzFile(fuzzFile) + .verbose(true) + .build(); + fuzzTest.execute(); + }); + + it("Assign to called lambda", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("LambdaAssignmentAndExecution") + .fuzzFile(fuzzFile) + .verbose(true) + .build(); + fuzzTest.execute(); + }); + + it("Assign to lambda and then execute", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .fuzzEntryPoint("LambdaAssignmentAndExecutionLater") + .fuzzFile(fuzzFile) + .verbose(true) + .build(); + fuzzTest.execute(); + }); + + it("Lambda variable declaration", () => { + const fuzzTest = new FuzzTestBuilder() + .customHooks([ + path.join(bugDetectorDirectory, "instrument-all.config.js"), + ]) + .dir(bugDetectorDirectory) + .dryRun(false) + .fuzzEntryPoint("LambdaVariableDeclaration") + .fuzzFile(fuzzFile) + .verbose(true) + .build(); + fuzzTest.execute(); + }); +}); diff --git a/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt b/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt new file mode 100644 index 000000000..244d8b2e8 --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt @@ -0,0 +1,2 @@ +# comment +"01234567890-Test" \ No newline at end of file diff --git a/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt b/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt new file mode 100644 index 000000000..32b7ab0ac --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt @@ -0,0 +1,3 @@ +# comment +# another comment +"test" \ No newline at end of file diff --git a/tests/bug-detectors/prototype-pollution/fuzz.js b/tests/bug-detectors/prototype-pollution/fuzz.js new file mode 100644 index 000000000..35cf4ffa5 --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/fuzz.js @@ -0,0 +1,109 @@ +/* + * Copyright 2023 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. + */ + +module.exports.BaseObjectPollution = function (data) { + const a = {}; + a.__proto__.polluted = true; +}; + +module.exports.BaseObjectPollutionWithSquareBraces = function (data) { + const a = {}; + a["__proto__"]["polluted"] = true; +}; + +module.exports.ArrayObjectPollution = function (data) { + const a = []; + a.__proto__.polluted = true; +}; + +module.exports.FunctionObjectPollution = function (data) { + const a = function () { + /* empty */ + }; + Function.__proto__.polluted = () => { + console.log("This is printed when the prototype of Function is polluted."); + }; + const c = () => { + /* empty */ + }; + c.polluted(); +}; + +module.exports.StringObjectPollution = function (data) { + const a = "a"; + a.__proto__.polluted = true; +}; + +module.exports.NumberObjectPollution = function (data) { + const a = 1000; + a.__proto__.polluted = true; +}; + +module.exports.BooleanObjectPollution = function (data) { + const a = false; + a.__proto__.polluted = true; +}; + +module.exports.ConstructorPrototype = function (data) { + const a = Object.create({}); + a.constructor.prototype.polluted = true; +}; + +module.exports.LocalPrototypePollution = function (data) { + const a = { __proto__: "test" }; + a.__proto__.polluted = true; +}; + +module.exports.PollutingAClass = function (data) { + class A {} + class B extends A {} + const b = new B(); + b.__proto__.polluted = true; +}; + +module.exports.ChangedToString = function (data) { + const a = { __proto__: "test" }; + a.__proto__.toString = () => { + return "test"; + }; + console.log(Object.getPrototypeOf(a)); +}; + +module.exports.DeletedToString = function (data) { + const a = { __proto__: "test" }; + delete a.__proto__.toString; +}; + +module.exports.DictionaryTest = function (data) { + /* empty */ +}; + +module.exports.TwoStagePollutionWithObjectCreation = function (data) { + class A {} + const a = new A(); + const b = a["__proto__"]; + b.polluted = true; + const c = new A(); // If we make a new object, PP will be detected. + console.log(c.polluted); +}; + +// Current instrumentation does not detect this. This test is currently unused. +module.exports.TwoStagePollution = function (data) { + class A {} + const a = new A(); + const b = a["__proto__"]; + b.polluted = true; // This can currently not be detected. +}; diff --git a/tests/bug-detectors/prototype-pollution/instrument-all-exclude-one.config.js b/tests/bug-detectors/prototype-pollution/instrument-all-exclude-one.config.js new file mode 100644 index 000000000..2dc32b77b --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/instrument-all-exclude-one.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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("prototype-pollution") + ?.instrumentAssignmentsAndVariableDeclarations() + ?.addExcludedExactMatch('{"polluted":true}'); diff --git a/tests/bug-detectors/prototype-pollution/instrument-all.config.js b/tests/bug-detectors/prototype-pollution/instrument-all.config.js new file mode 100644 index 000000000..b9c7e3f39 --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/instrument-all.config.js @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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( + "prototype-pollution", +)?.instrumentAssignmentsAndVariableDeclarations(); diff --git a/tests/bug-detectors/prototype-pollution/instrumentation-correctness-tests.js b/tests/bug-detectors/prototype-pollution/instrumentation-correctness-tests.js new file mode 100644 index 000000000..ceb46e3f4 --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/instrumentation-correctness-tests.js @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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. + */ + +module.exports.OnePlusOne = function (data) { + let a = 10; + let b = 20; + let c = a + b; + a = a + 1; + b = a = 1 + 10; + expect(a).toBe(11); + expect(b).toBe(11); + expect(c).toBe(30); +}; + +module.exports.LambdaAssignmentAndExecution = function (data) { + let a; + a = ((n) => { + return n + 1; + })(10); + expect(a).toBe(11); +}; + +module.exports.LambdaAssignmentAndExecutionLater = function (data) { + let a; + a = (n) => { + return n + 1; + }; + expect(a(10)).toBe(11); +}; + +module.exports.LambdaVariableDeclaration = function (data) { + const a = (n) => { + return n + 1; + }; + expect(a(10)).toBe(11); +}; + +function expect(value) { + return { + toBe: function (expected) { + if (value !== expected) { + throw new Error(`Expected ${expected} but got ${value}`); + } + }, + }; +} diff --git a/tests/bug-detectors/prototype-pollution/package.json b/tests/bug-detectors/prototype-pollution/package.json new file mode 100644 index 000000000..9719be17d --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/package.json @@ -0,0 +1,27 @@ +{ + "name": "jazzerjs-prototype-pollution-tests", + "version": "1.0.0", + "description": "Tests for the Prototype Pollution bug detector", + "scripts": { + "test": "jest", + "fuzz": "JAZZER_FUZZ=1 jest" + }, + "devDependencies": { + "@jazzer.js/jest-runner": "file:../../packages/jest-runner", + "eslint-plugin-jest": "^27.1.3" + }, + "jest": { + "projects": [ + { + "runner": "@jazzer.js/jest-runner", + "displayName": { + "name": "Jazzer.js", + "color": "cyan" + }, + "testMatch": [ + "/**/*.fuzz.js" + ] + } + ] + } +} diff --git a/tests/bug-detectors/prototype-pollution/tests.fuzz.js b/tests/bug-detectors/prototype-pollution/tests.fuzz.js new file mode 100644 index 000000000..bbf1b2a48 --- /dev/null +++ b/tests/bug-detectors/prototype-pollution/tests.fuzz.js @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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. + */ + +describe("Prototype Pollution Jest tests", () => { + it.fuzz("Pollution of Object", (data) => { + const a = {}; + a.__proto__.a = 10; + }); + + it.fuzz("Assignments", (data) => { + let a; + a = { __proto__: { a: 10 } }; + console.log(a.__proto__); + }); + + it.fuzz("Variable declarations", (data) => { + const a = { __proto__: { a: 10 } }; + }); + + it.fuzz("Fuzzing mode pollution of Object", (data) => { + const a = {}; + a.__proto__.a = 10; + }); +}); diff --git a/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Assignments/empty b/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Assignments/empty new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Pollution_of_Object/empty b/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Pollution_of_Object/empty new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Variable_declarations/empty b/tests/bug-detectors/prototype-pollution/tests.fuzz/Prototype_Pollution_Jest_tests/Variable_declarations/empty new file mode 100644 index 000000000..e69de29bb diff --git a/tests/helpers.js b/tests/helpers.js index 10283e5fd..4959e17b0 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -27,44 +27,39 @@ const JestRegressionExitCode = "1"; const WindowsExitCode = "1"; class FuzzTest { - sync; - runs; - verbose; - fuzzEntryPoint; - dir; - disableBugDetectors; - forkMode; - seed; - jestTestFile; - jestTestNamePattern; - jestRunInFuzzingMode; - coverage; - constructor( - sync, - runs, - verbose, - fuzzEntryPoint, + customHooks, + dictionaries, dir, disableBugDetectors, + dryRun, forkMode, - seed, + fuzzEntryPoint, + fuzzFile, + jestRunInFuzzingMode, jestTestFile, jestTestName, - jestRunInFuzzingMode, + runs, + seed, + sync, + verbose, coverage, ) { - this.sync = sync; - this.runs = runs; - this.verbose = verbose; - this.fuzzEntryPoint = fuzzEntryPoint; + this.customHooks = customHooks; + this.dictionaries = dictionaries; this.dir = dir; this.disableBugDetectors = disableBugDetectors; + this.dryRun = dryRun; this.forkMode = forkMode; - this.seed = seed; + this.fuzzEntryPoint = fuzzEntryPoint; + this.fuzzFile = fuzzFile; + this.jestRunInFuzzingMode = jestRunInFuzzingMode; this.jestTestFile = jestTestFile; this.jestTestNamePattern = jestTestName; - this.jestRunInFuzzingMode = jestRunInFuzzingMode; + this.runs = runs; + this.seed = seed; + this.sync = sync; + this.verbose = verbose; this.coverage = coverage; } @@ -73,26 +68,45 @@ class FuzzTest { this.executeWithJest(); return; } - const options = ["jazzer", "fuzz"]; + const options = ["jazzer", this.fuzzFile]; options.push("-f " + this.fuzzEntryPoint); if (this.sync) options.push("--sync"); for (const bugDetector of this.disableBugDetectors) { options.push("--disable_bug_detectors=" + bugDetector); } + + if (this.customHooks) { + options.push("--custom_hooks=" + this.customHooks); + } + options.push("--dryRun=" + this.dryRun); if (this.coverage) options.push("--coverage"); options.push("--"); options.push("-runs=" + this.runs); if (this.forkMode) options.push("-fork=" + this.forkMode); options.push("-seed=" + this.seed); + for (const dictionary of this.dictionaries) { + options.push("-dict=" + dictionary); + } this.runTest("npx", options, { ...process.env }); } executeWithJest() { + // Put together the libfuzzer options. + const fuzzerOptions = ["-runs=" + this.runs, "-seed=" + this.seed]; + const dictionaries = this.dictionaries.map( + (dictionary) => "-dict=" + dictionary, + ); + fuzzerOptions.push(...dictionaries); + // Put together the jest config. const config = { sync: this.sync, - bugDetectors: this.disableBugDetectors, - fuzzerOptions: ["-runs=" + this.runs, "-seed=" + this.seed], + include: [this.jestTestFile], + disableBugDetectors: this.disableBugDetectors, + fuzzerOptions: fuzzerOptions, + customHooks: this.customHooks, + dryRun: this.dryRun, + mode: this.jestRunInFuzzingMode ? "fuzzing" : "regression", }; // Write jest config file even if it exists @@ -107,11 +121,7 @@ class FuzzTest { this.jestTestFile, '--testNamePattern="' + this.jestTestNamePattern + '"', ]; - let env = { ...process.env }; - if (this.jestRunInFuzzingMode) { - env.JAZZER_FUZZ = "1"; - } - this.runTest(cmd, options, env); + this.runTest(cmd, options, { ...process.env }); } runTest(cmd, options, env) { @@ -140,14 +150,18 @@ class FuzzTestBuilder { _sync = false; _runs = 0; _verbose = false; + _dryRun = false; _fuzzEntryPoint = ""; _dir = ""; - _disableBugDetectors = ""; + _fuzzFile = "fuzz"; + _disableBugDetectors = []; + _customHooks = undefined; _forkMode = 0; _seed = 100; _jestTestFile = ""; _jestTestName = ""; _jestRunInFuzzingMode = false; + _dictionaries = []; _coverage = false; /** @@ -176,6 +190,14 @@ class FuzzTestBuilder { return this; } + /** + * @param {boolean} dryRun + */ + dryRun(dryRun) { + this._dryRun = dryRun; + return this; + } + /** * @param {string} fuzzEntryPoint */ @@ -193,15 +215,37 @@ class FuzzTestBuilder { return this; } + fuzzFile(fuzzFile) { + this._fuzzFile = fuzzFile; + return this; + } + /** * @param {string[]} bugDetectors - bug detectors to disable. This will set Jazzer.js's command line flag * --disableBugDetectors=bugDetector1 --disableBugDetectors=bugDetector2 ... */ disableBugDetectors(bugDetectors) { + if (!Array.isArray(bugDetectors)) { + bugDetectors = [bugDetectors]; + } this._disableBugDetectors = bugDetectors; return this; } + /** + * @param {string[]} file - an array of strings that represent the custom hooks files. + * @returns {FuzzTestBuilder} + */ + customHooks(file) { + // make sure it's an array of strings + if (!Array.isArray(file)) { + // throw error + throw new Error("customHooks must be an array of strings"); + } + this._customHooks = file; + return this; + } + /** * @param {number} forkMode - sets libFuzzer's fork mode (-fork=). Default is 0 (disabled). * When enabled and greater zero, the number @@ -245,6 +289,14 @@ class FuzzTestBuilder { return this; } + /** + * @param {string[]} dictionaries + */ + dictionaries(dictionaries) { + this._dictionaries = dictionaries; + return this; + } + coverage(coverage) { this._coverage = coverage; return this; @@ -260,17 +312,21 @@ class FuzzTestBuilder { ); } return new FuzzTest( - this._sync, - this._runs, - this._verbose, - this._fuzzEntryPoint, + this._customHooks, + this._dictionaries, this._dir, this._disableBugDetectors, + this._dryRun, this._forkMode, - this._seed, + this._fuzzEntryPoint, + this._fuzzFile, + this._jestRunInFuzzingMode, this._jestTestFile, this._jestTestName, - this._jestRunInFuzzingMode, + this._runs, + this._seed, + this._sync, + this._verbose, this._coverage, ); } From 022a7aaf3559ce1c56d40200c471c97e554d75f7 Mon Sep 17 00:00:00 2001 From: Norbert Schneider Date: Fri, 14 Jul 2023 11:28:56 +0200 Subject: [PATCH 2/5] Disentangle dependencies from bug-detectors to core The bug-detector module should use the normal user-facing API to report findings, register callbacks and the like to verify that those work correctly and be able to be used as examples. --- .../command-injection/custom-hooks.js | 5 +- package-lock.json | 8 ++- packages/bug-detectors/DEVELOPMENT.md | 18 +++--- packages/bug-detectors/index.ts | 60 ------------------- .../internal/command-injection.ts | 5 +- .../bug-detectors/internal/path-traversal.ts | 13 ++-- .../internal/prototype-pollution.ts | 2 +- packages/bug-detectors/package.json | 3 +- packages/bug-detectors/tsconfig.json | 2 +- packages/core/{jazzer.ts => api.ts} | 16 ++--- packages/core/core.ts | 53 +++++++++++++--- .../findings.ts => core/finding.ts} | 17 +++--- packages/core/package.json | 3 +- packages/core/tsconfig.json | 3 - 14 files changed, 92 insertions(+), 116 deletions(-) rename packages/core/{jazzer.ts => api.ts} (68%) rename packages/{bug-detectors/findings.ts => core/finding.ts} (60%) diff --git a/examples/bug-detectors/command-injection/custom-hooks.js b/examples/bug-detectors/command-injection/custom-hooks.js index c82cc7373..8893acc6b 100644 --- a/examples/bug-detectors/command-injection/custom-hooks.js +++ b/examples/bug-detectors/command-injection/custom-hooks.js @@ -17,8 +17,7 @@ */ const { registerReplaceHook } = require("@jazzer.js/hooking"); -const { reportFinding } = require("@jazzer.js/bug-detectors"); -const { fuzzer } = require("@jazzer.js/fuzzer"); +const { guideTowardsEquality, reportFinding } = require("@jazzer.js/core"); /** * Custom bug detector for command injection. This hook does not call the original function (execSync) for two reasons: @@ -39,6 +38,6 @@ registerReplaceHook( `Command Injection in spawnSync(): called with '${command}'`, ); } - fuzzer.tracer.guideTowardsEquality(command, "jaz_zer", hookId); + guideTowardsEquality(command, "jaz_zer", hookId); }, ); diff --git a/package-lock.json b/package-lock.json index f22e503d5..57c7df6db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8744,7 +8744,8 @@ "version": "1.5.1", "license": "Apache-2.0", "dependencies": { - "@jazzer.js/fuzzer": "*" + "@jazzer.js/core": "*", + "@jazzer.js/hooking": "*" }, "devDependencies": {}, "engines": { @@ -8758,6 +8759,7 @@ "license": "Apache-2.0", "dependencies": { "@jazzer.js/bug-detectors": "*", + "@jazzer.js/fuzzer": "*", "@jazzer.js/hooking": "*", "@jazzer.js/instrumentor": "*", "istanbul-lib-coverage": "^3.2.0", @@ -9474,13 +9476,15 @@ "@jazzer.js/bug-detectors": { "version": "file:packages/bug-detectors", "requires": { - "@jazzer.js/fuzzer": "*" + "@jazzer.js/core": "*", + "@jazzer.js/hooking": "*" } }, "@jazzer.js/core": { "version": "file:packages/core", "requires": { "@jazzer.js/bug-detectors": "*", + "@jazzer.js/fuzzer": "*", "@jazzer.js/hooking": "*", "@jazzer.js/instrumentor": "*", "@types/yargs": "^17.0.24", diff --git a/packages/bug-detectors/DEVELOPMENT.md b/packages/bug-detectors/DEVELOPMENT.md index f751849f7..05946627e 100644 --- a/packages/bug-detectors/DEVELOPMENT.md +++ b/packages/bug-detectors/DEVELOPMENT.md @@ -84,30 +84,34 @@ instrumentationGuard.add("VariableDeclaration", resultDeclarator); ## Guiding the fuzzing process -Import the fuzzer object from the `@jazzer.js/fuzzer` package: +Import the guiding functions from the `@jazzer.js/core` package: ```typescript -import { fuzzer } from "@jazzer.js/fuzzer"; +import { + guideTowardsEquality, + guideTowardsContainment, + exploreState, +} from "@jazzer.js/core"; ``` There are several ways to guide the fuzzing process: - ```typescript - fuzzer.tracer.guideTowardsEquality(current: string, target: string, id: number) + guideTowardsEquality(current: string, target: string, id: number) ``` Instructs the fuzzer to guide its mutations towards making `current` equal to `target`. - ```typescript - fuzzer.tracer.guideTowardsContainment(needle: string, haystack: string, id: number) + guideTowardsContainment(needle: string, haystack: string, id: number) ``` Instructs the fuzzer to guide its mutations towards making `haystack` contain `needle` as a substring. - ```typescript - fuzzer.tracer.exploreState(state: number, id: number) + exploreState(state: number, id: number) ``` Instructs the fuzzer to attain as many possible values for the absolute value @@ -128,10 +132,10 @@ syntax used by the dictionary is documented ## Report findings -To report a finding, use the `reportFinding` function from -`@jazzer.js/bug-detectors`: +To report a finding, use the `reportFinding` function from `@jazzer.js/core`: ```typescript +import { reportFinding } from "@jazzer.js/core"; reportFinding(findingMessage: string) ``` diff --git a/packages/bug-detectors/index.ts b/packages/bug-detectors/index.ts index f7b05e75b..9804a60b5 100644 --- a/packages/bug-detectors/index.ts +++ b/packages/bug-detectors/index.ts @@ -1,5 +1,4 @@ /* - * Copyright 2023 Code Intelligence GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -15,63 +14,4 @@ * limitations under the License. */ -import * as findings from "./findings"; -import * as fs from "fs"; -import * as path from "path"; export { getBugDetectorConfiguration } from "./configuration"; - -// Export user-facing API for writing custom bug detectors. -export { - reportFinding, - getFirstFinding, - clearFirstFinding, - Finding, -} from "./findings"; - -// Global API for bug detectors that can be used by instrumentation plugins. -export interface BugDetectors { - reportFinding: typeof findings.reportFinding; -} - -export const bugDetectors: BugDetectors = { - reportFinding: findings.reportFinding, -}; - -// Filters out disabled bug detectors and prepares all the others for dynamic import. -export function getFilteredBugDetectorPaths( - bugDetectorsDirectory: string, - disableBugDetectors: string[], -): string[] { - const disablePatterns = disableBugDetectors.map( - (pattern: string) => new RegExp(pattern), - ); - return ( - fs - .readdirSync(bugDetectorsDirectory) - // The compiled "internal" directory contains several files such as .js.map and .d.ts. - // We only need the .js files. - // Here we also filter out bug detectors that should be disabled. - .filter((bugDetectorPath) => { - if (!bugDetectorPath.endsWith(".js")) { - return false; - } - - // Dynamic imports need .js files. - 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), - ); - - if (shouldDisable) { - console.log( - `Skip loading bug detector "${bugDetectorName}" because of user-provided pattern.`, - ); - } - return !shouldDisable; - }) - // Get absolute paths for each bug detector. - .map((file) => path.join(bugDetectorsDirectory, file)) - ); -} diff --git a/packages/bug-detectors/internal/command-injection.ts b/packages/bug-detectors/internal/command-injection.ts index 5682ce969..aab5e61f9 100644 --- a/packages/bug-detectors/internal/command-injection.ts +++ b/packages/bug-detectors/internal/command-injection.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { reportFinding } from "../findings"; -import { fuzzer } from "@jazzer.js/fuzzer"; +import { guideTowardsContainment, reportFinding } from "@jazzer.js/core"; import { registerBeforeHook } from "@jazzer.js/hooking"; /** @@ -51,7 +50,7 @@ for (const functionName of functionNames) { `Command Injection in ${functionName}(): called with '${firstArgument}'`, ); } - fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); + guideTowardsContainment(firstArgument, goal, hookId); }; registerBeforeHook(functionName, moduleName, false, beforeHook); diff --git a/packages/bug-detectors/internal/path-traversal.ts b/packages/bug-detectors/internal/path-traversal.ts index 0b430328a..b7dbe8cec 100644 --- a/packages/bug-detectors/internal/path-traversal.ts +++ b/packages/bug-detectors/internal/path-traversal.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { reportFinding } from "../findings"; -import { fuzzer } from "@jazzer.js/fuzzer"; +import { reportFinding, guideTowardsContainment } from "@jazzer.js/core"; import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking"; /** @@ -138,7 +137,7 @@ for (const module of modulesToHook) { `Path Traversal in ${functionName}(): called with '${firstArgument}'`, ); } - fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); + guideTowardsContainment(firstArgument, goal, hookId); }; registerBeforeHook(functionName, module.moduleName, false, beforeHook); @@ -184,14 +183,10 @@ for (const module of functionsWithTwoTargets) { ` and '${secondArgument}'`, ); } - fuzzer.tracer.guideTowardsContainment(firstArgument, goal, hookId); + guideTowardsContainment(firstArgument, goal, hookId); // We don't want to confuse the fuzzer guidance with the same hookId for both function arguments. // Therefore, we use an extra hookId for the second argument. - fuzzer.tracer.guideTowardsContainment( - secondArgument, - goal, - extraHookId, - ); + guideTowardsContainment(secondArgument, goal, extraHookId); }; }; diff --git a/packages/bug-detectors/internal/prototype-pollution.ts b/packages/bug-detectors/internal/prototype-pollution.ts index 091edc04d..68b98f911 100644 --- a/packages/bug-detectors/internal/prototype-pollution.ts +++ b/packages/bug-detectors/internal/prototype-pollution.ts @@ -16,7 +16,7 @@ import { AssignmentExpression, Identifier, Node } from "@babel/types"; import { NodePath, PluginTarget, types } from "@babel/core"; -import { reportFinding } from "../findings"; +import { reportFinding } from "@jazzer.js/core"; import { addDictionary, instrumentationGuard, diff --git a/packages/bug-detectors/package.json b/packages/bug-detectors/package.json index aab872fb1..cdb8c6a0e 100644 --- a/packages/bug-detectors/package.json +++ b/packages/bug-detectors/package.json @@ -16,7 +16,8 @@ "main": "dist/index.js", "types": "dist/index.d.js", "dependencies": { - "@jazzer.js/fuzzer": "*" + "@jazzer.js/core": "*", + "@jazzer.js/hooking": "*" }, "devDependencies": {}, "engines": { diff --git a/packages/bug-detectors/tsconfig.json b/packages/bug-detectors/tsconfig.json index 643b31181..e6f459089 100644 --- a/packages/bug-detectors/tsconfig.json +++ b/packages/bug-detectors/tsconfig.json @@ -6,7 +6,7 @@ }, "references": [ { - "path": "../fuzzer" + "path": "../core" }, { "path": "../hooking" diff --git a/packages/core/jazzer.ts b/packages/core/api.ts similarity index 68% rename from packages/core/jazzer.ts rename to packages/core/api.ts index 0c5fcc722..597a639db 100644 --- a/packages/core/jazzer.ts +++ b/packages/core/api.ts @@ -1,6 +1,5 @@ -#!/usr/bin/env node /* - * Copyright 2022 Code Intelligence GmbH + * Copyright 2023 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. @@ -17,13 +16,14 @@ import { fuzzer } from "@jazzer.js/fuzzer"; -export interface Jazzer { - guideTowardsEquality: typeof fuzzer.tracer.guideTowardsEquality; - guideTowardsContainment: typeof fuzzer.tracer.guideTowardsContainment; - exploreState: typeof fuzzer.tracer.exploreState; -} +export { reportFinding } from "./finding"; -export const jazzer: Jazzer = { +export const guideTowardsEquality = fuzzer.tracer.guideTowardsEquality; +export const guideTowardsContainment = fuzzer.tracer.guideTowardsContainment; +export const exploreState = fuzzer.tracer.exploreState; + +// Export jazzer object for backwards compatibility. +export const jazzer = { guideTowardsEquality: fuzzer.tracer.guideTowardsEquality, guideTowardsContainment: fuzzer.tracer.guideTowardsContainment, exploreState: fuzzer.tracer.exploreState, diff --git a/packages/core/core.ts b/packages/core/core.ts index 500e8c84d..c41fb2a5e 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import path from "path"; +import { builtinModules } from "module"; +import * as process from "process"; import * as tmp from "tmp"; import * as fs from "fs"; @@ -23,19 +25,13 @@ import * as reports from "istanbul-reports"; import * as fuzzer from "@jazzer.js/fuzzer"; import * as hooking from "@jazzer.js/hooking"; -import { - clearFirstFinding, - Finding, - getFilteredBugDetectorPaths, - getFirstFinding, -} from "@jazzer.js/bug-detectors"; +import { clearFirstFinding, Finding, getFirstFinding } from "./finding"; import { FileSyncIdStrategy, Instrumentor, MemorySyncIdStrategy, registerInstrumentor, } from "@jazzer.js/instrumentor"; -import { builtinModules } from "module"; // Remove temporary files on exit tmp.setGracefulCleanup(); @@ -121,6 +117,45 @@ export async function initFuzzing(options: Options): Promise { await hookBuiltInFunctions(hooking.hookManager); } +// Filters out disabled bug detectors and prepares all the others for dynamic import. +function getFilteredBugDetectorPaths( + bugDetectorsDirectory: string, + disableBugDetectors: string[], +): string[] { + const disablePatterns = disableBugDetectors.map( + (pattern: string) => new RegExp(pattern), + ); + return ( + fs + .readdirSync(bugDetectorsDirectory) + // The compiled "internal" directory contains several files such as .js.map and .d.ts. + // We only need the .js files. + // Here we also filter out bug detectors that should be disabled. + .filter((bugDetectorPath) => { + if (!bugDetectorPath.endsWith(".js")) { + return false; + } + + // Dynamic imports need .js files. + 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), + ); + + if (shouldDisable) { + console.log( + `Skip loading bug detector "${bugDetectorName}" because of user-provided pattern.`, + ); + } + return !shouldDisable; + }) + // Get absolute paths for each bug detector. + .map((file) => path.join(bugDetectorsDirectory, file)) + ); +} + // Built-in functions cannot be hooked by the instrumentor. We hook them by overwriting them at the module level. async function hookBuiltInFunctions(hookManager: hooking.HookManager) { for (const builtinModule of builtinModules) { @@ -600,6 +635,6 @@ export function ensureFilepath(filePath: string): string { : fullPath + ".js"; } -export type { Jazzer } from "./jazzer"; -export { jazzer } from "./jazzer"; +// Export public API from within core module for easy access. +export * from "./api"; export { FuzzedDataProvider } from "./FuzzedDataProvider"; diff --git a/packages/bug-detectors/findings.ts b/packages/core/finding.ts similarity index 60% rename from packages/bug-detectors/findings.ts rename to packages/core/finding.ts index d55c6c8d8..9fc9be2b2 100644 --- a/packages/bug-detectors/findings.ts +++ b/packages/core/finding.ts @@ -1,5 +1,5 @@ /* - * Copyright 2022 Code Intelligence GmbH + * Copyright 2023 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. @@ -17,27 +17,28 @@ export class Finding extends Error {} -// The first finding found by any bug detector will be saved here. -// This is a global variable shared between the core-library (read, reset) and the bug detectors (write). -// It is cleared every time when the fuzzer is finished processing an input (only relevant for modes where the fuzzing -// continues after finding an error, e.g. fork mode, Jest regression mode, fuzzing that ignores errors mode, etc.). +// The first finding reported by any bug detector will be saved here. +// This variable has to be cleared every time when the fuzzer is finished +// processing an input (only relevant for modes where the fuzzing continues +// after finding an error, e.g. fork mode, Jest regression mode, fuzzing that +// ignores errors mode, etc.). let firstFinding: Finding | undefined; export function getFirstFinding(): Finding | undefined { return firstFinding; } -// Clear the finding saved by the bug detector before the fuzzer continues with a new input. export function clearFirstFinding(): void { firstFinding = undefined; } /** - * Saves the first finding found by any bug detector and throws it. + * Save the first finding reported by any bug detector and throw it to + * potentially abort the current execution. * * @param findingMessage - The finding to be saved and thrown. */ -export function reportFinding(findingMessage: string): void { +export function reportFinding(findingMessage: string): void | never { // After saving the first finding, ignore all subsequent errors. if (firstFinding) { return; diff --git a/packages/core/package.json b/packages/core/package.json index 2b02fa4e5..923b02ef9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,9 +19,10 @@ "jazzer": "dist/cli.js" }, "dependencies": { + "@jazzer.js/bug-detectors": "*", + "@jazzer.js/fuzzer": "*", "@jazzer.js/hooking": "*", "@jazzer.js/instrumentor": "*", - "@jazzer.js/bug-detectors": "*", "tmp": "^0.2.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.0", diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index cb739c09d..bd4e839ef 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -10,9 +10,6 @@ }, { "path": "../hooking" - }, - { - "path": "../bug-detectors" } ] } From 82eddb66a5482c30c3ab4fe12f93d18d7bfc715b Mon Sep 17 00:00:00 2001 From: Norbert Schneider Date: Fri, 14 Jul 2023 15:33:49 +0200 Subject: [PATCH 3/5] Split up hook manager responsibilities HookManager had too many responsibilities. Extract those out and move to functionally more fitting modules to simplify the project layout. Furthermore, hooks and bug detectors should only use the publicly exposed API from core and not reach into internal modules. --- .gitignore | 4 - .../bug-detectors/path-traversal/.gitignore | 2 + .../internal/prototype-pollution.ts | 8 +- packages/core/api.ts | 12 + packages/core/callback.ts | 52 +++++ packages/core/core.ts | 16 +- packages/core/dictionary.ts | 37 +++ packages/fuzzer/trace.ts | 35 ++- packages/hooking/hook.ts | 129 ----------- packages/hooking/index.ts | 1 + packages/hooking/manager.ts | 210 ++++++------------ packages/hooking/tracker.ts | 155 +++++++++++++ packages/instrumentor/guard.ts | 47 ++++ packages/instrumentor/instrument.ts | 5 +- packages/instrumentor/plugin.ts | 39 ++++ .../instrumentor/plugins/functionHooks.ts | 4 +- tests/bug-detectors/.gitignore | 2 + 17 files changed, 454 insertions(+), 304 deletions(-) create mode 100644 examples/bug-detectors/path-traversal/.gitignore create mode 100644 packages/core/callback.ts create mode 100644 packages/core/dictionary.ts create mode 100644 packages/hooking/tracker.ts create mode 100644 packages/instrumentor/guard.ts create mode 100644 packages/instrumentor/plugin.ts create mode 100644 tests/bug-detectors/.gitignore diff --git a/.gitignore b/.gitignore index 92c55f10d..b0410ba28 100644 --- a/.gitignore +++ b/.gitignore @@ -52,8 +52,4 @@ node_modules/ # Output of 'npm pack' *.tgz -# corpus files in the path traversal example except for manual test.zip -examples/bug-detectors/path-traversal/corpus/ - .JazzerJs-merged-dictionaries -tests/bug-detectors/*/.jazzerjsrc.json \ No newline at end of file diff --git a/examples/bug-detectors/path-traversal/.gitignore b/examples/bug-detectors/path-traversal/.gitignore new file mode 100644 index 000000000..56f3321d8 --- /dev/null +++ b/examples/bug-detectors/path-traversal/.gitignore @@ -0,0 +1,2 @@ +!corpus/test.zip +corpus/* diff --git a/packages/bug-detectors/internal/prototype-pollution.ts b/packages/bug-detectors/internal/prototype-pollution.ts index 68b98f911..cad73eed7 100644 --- a/packages/bug-detectors/internal/prototype-pollution.ts +++ b/packages/bug-detectors/internal/prototype-pollution.ts @@ -16,13 +16,13 @@ import { AssignmentExpression, Identifier, Node } from "@babel/types"; import { NodePath, PluginTarget, types } from "@babel/core"; -import { reportFinding } from "@jazzer.js/core"; import { - addDictionary, - instrumentationGuard, + reportFinding, registerAfterEachCallback, + addDictionary, registerInstrumentationPlugin, -} from "@jazzer.js/hooking"; + instrumentationGuard, +} from "@jazzer.js/core"; import { bugDetectorConfigurations } from "../configuration"; diff --git a/packages/core/api.ts b/packages/core/api.ts index 597a639db..e4dd8a451 100644 --- a/packages/core/api.ts +++ b/packages/core/api.ts @@ -16,6 +16,18 @@ import { fuzzer } from "@jazzer.js/fuzzer"; +// Central place to export all public API functions to be used in fuzz targets, +// hooks and bug detectors. Don't use internal functions directly from those. + +export { + registerInstrumentationPlugin, + instrumentationGuard, +} from "@jazzer.js/instrumentor"; +export { + registerAfterEachCallback, + registerBeforeEachCallback, +} from "./callback"; +export { addDictionary } from "./dictionary"; export { reportFinding } from "./finding"; export const guideTowardsEquality = fuzzer.tracer.guideTowardsEquality; diff --git a/packages/core/callback.ts b/packages/core/callback.ts new file mode 100644 index 000000000..d91ab7795 --- /dev/null +++ b/packages/core/callback.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2023 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. + */ + +export type Thunk = () => void; + +/** + * Callbacks can be registered in fuzz targets or bug detectors to be executed + * before or after each fuzz target invocation. + */ +export class Callbacks { + private _afterEachCallbacks: Array = []; + private _beforeEachCallbacks: Array = []; + + registerAfterEachCallback(callback: Thunk) { + this._afterEachCallbacks.push(callback); + } + + registerBeforeEachCallback(callback: Thunk) { + this._beforeEachCallbacks.push(callback); + } + + runAfterEachCallbacks() { + this._afterEachCallbacks.forEach((c) => c()); + } + + runBeforeEachCallbacks() { + this._beforeEachCallbacks.forEach((c) => c()); + } +} + +export const callbacks = new Callbacks(); + +export function registerAfterEachCallback(callback: Thunk) { + callbacks.registerAfterEachCallback(callback); +} + +export function registerBeforeEachCallback(callback: Thunk) { + callbacks.registerBeforeEachCallback(callback); +} diff --git a/packages/core/core.ts b/packages/core/core.ts index c41fb2a5e..1d21fe1f3 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -32,6 +32,8 @@ import { MemorySyncIdStrategy, registerInstrumentor, } from "@jazzer.js/instrumentor"; +import { callbacks } from "./callback"; +import { dictionaries } from "./dictionary"; // Remove temporary files on exit tmp.setGracefulCleanup(); @@ -473,7 +475,7 @@ function buildFuzzerOptions(options: Options): string[] { let dictionary = ""; // Extract dictionaries from bug detectors. - for (const dict of hooking.hookManager.getDictionaries()) { + for (const dict of dictionaries.dictionary) { // Make an empty dictionary file. if (!shouldUseDictionaries) { shouldUseDictionaries = true; @@ -552,7 +554,7 @@ export function wrapFuzzFunctionForBugDetection( let fuzzTargetError: unknown; let result: void | Promise = undefined; try { - hooking.hookManager.runBeforeEachCallbacks(); + callbacks.runBeforeEachCallbacks(); result = (originalFuzzFn as fuzzer.FuzzTargetAsyncOrValue)(data); // Explicitly set promise handlers to process findings, but still return // the fuzz target result directly, so that sync execution is still @@ -560,7 +562,7 @@ export function wrapFuzzFunctionForBugDetection( if (result instanceof Promise) { result = result.then( (result) => { - hooking.hookManager.runAfterEachCallbacks(); + callbacks.runAfterEachCallbacks(); return throwIfError() ?? result; }, (reason) => { @@ -573,7 +575,7 @@ export function wrapFuzzFunctionForBugDetection( } // Promises are handled above, so we only need to handle sync results here. if (!(result instanceof Promise)) { - hooking.hookManager.runAfterEachCallbacks(); + callbacks.runAfterEachCallbacks(); } return throwIfError(fuzzTargetError) ?? result; }; @@ -584,18 +586,18 @@ export function wrapFuzzFunctionForBugDetection( ): void | Promise => { let result: void | Promise = undefined; try { - hooking.hookManager.runBeforeEachCallbacks(); + callbacks.runBeforeEachCallbacks(); // Return result of fuzz target to enable sanity checks in C++ part. result = originalFuzzFn(data, (err?: Error) => { const finding = getFirstFinding(); if (finding !== undefined) { clearFirstFinding(); } - hooking.hookManager.runAfterEachCallbacks(); + callbacks.runAfterEachCallbacks(); done(finding ?? err); }); } catch (e) { - hooking.hookManager.runAfterEachCallbacks(); + callbacks.runAfterEachCallbacks(); throwIfError(e); } return result; diff --git a/packages/core/dictionary.ts b/packages/core/dictionary.ts new file mode 100644 index 000000000..2a6573e30 --- /dev/null +++ b/packages/core/dictionary.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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. + */ + +/** + * Dictionaries can be used to provide additional mutation suggestions to the + * fuzzer. + */ +export class Dictionaries { + private _dictionary: string[] = []; + + get dictionary() { + return this._dictionary; + } + + addDictionary(dictionary: string[]) { + this._dictionary.push(dictionary.join("\n")); + } +} + +export const dictionaries = new Dictionaries(); + +export function addDictionary(...dictionary: string[]) { + dictionaries.addDictionary(dictionary); +} diff --git a/packages/fuzzer/trace.ts b/packages/fuzzer/trace.ts index 5b3e0ceb3..ff195c4ea 100644 --- a/packages/fuzzer/trace.ts +++ b/packages/fuzzer/trace.ts @@ -157,11 +157,16 @@ export const tracer: Tracer = { * @param target a string that `current` should become equal to, but currently isn't * @param id a (probabilistically) unique identifier for this particular compare hint */ -export function guideTowardsEquality( - current: string, - target: string, - id: number, -) { +function guideTowardsEquality(current: string, target: string, id: number) { + // Check types as JavaScript fuzz targets could provide wrong ones. + // noinspection SuspiciousTypeOfGuard + if ( + typeof current !== "string" || + typeof target !== "string" || + typeof id !== "number" + ) { + return; + } tracer.traceUnequalStrings(id, current, target); } @@ -177,13 +182,14 @@ export function guideTowardsEquality( * @param haystack a non-constant string observed during fuzz target execution * @param id a (probabilistically) unique identifier for this particular compare hint */ -export function guideTowardsContainment( - needle: string, - haystack: string, - id: number, -) { - // needle and haystack should be both strings - if (typeof needle !== "string" || typeof haystack !== "string") { +function guideTowardsContainment(needle: string, haystack: string, id: number) { + // Check types as JavaScript fuzz targets could provide wrong ones. + // noinspection SuspiciousTypeOfGuard + if ( + typeof needle !== "string" || + typeof haystack !== "string" || + typeof id !== "number" + ) { return; } tracer.traceStringContainment(id, needle, haystack); @@ -204,5 +210,10 @@ export function guideTowardsContainment( * @param id a (probabilistically) unique identifier for this particular state hint */ export function exploreState(state: number, id: number) { + // Check types as JavaScript fuzz targets could provide wrong ones. + // noinspection SuspiciousTypeOfGuard + if (typeof state !== "string" || typeof id !== "number") { + return; + } tracer.tracePcIndir(id, state); } diff --git a/packages/hooking/hook.ts b/packages/hooking/hook.ts index f531f1cc4..b7b82b9df 100644 --- a/packages/hooking/hook.ts +++ b/packages/hooking/hook.ts @@ -16,135 +16,6 @@ /* eslint @typescript-eslint/no-explicit-any: 0 */ -export interface TrackedHook { - target: string; - pkg: string; -} - -// HookTracker keeps track of hooks that were applied, are available, and were not applied. -// This is helpful when debugging custom hooks and bug detectors. -class HookTracker { - private _applied = new HookTable(); - private _available = new HookTable(); - private _notApplied = new HookTable(); - - print() { - console.log("DEBUG: [Hook] Summary:"); - console.log("DEBUG: [Hook] Not applied: " + this._notApplied.length); - this._notApplied.serialize().forEach((hook) => { - console.log(`DEBUG: [Hook] not applied: ${hook.pkg} -> ${hook.target}`); - }); - console.log("DEBUG: [Hook] Applied: " + this._applied.length); - this._applied.serialize().forEach((hook) => { - console.log(`DEBUG: [Hook] applied: ${hook.pkg} -> ${hook.target}`); - }); - console.log("DEBUG: [Hook] Available: " + this._available.length); - this._available.serialize().forEach((hook) => { - console.log(`DEBUG: [Hook] available: ${hook.pkg} -> ${hook.target}`); - }); - } - - categorizeUnknown(requestedHooks: Hook[]): this { - requestedHooks.forEach((hook) => { - if ( - !this._applied.has(hook.pkg, hook.target) && - !this._available.has(hook.pkg, hook.target) - ) { - this.addNotApplied(hook.pkg, hook.target); - } - }); - return this; - } - - clear() { - this._applied.clear(); - this._notApplied.clear(); - this._available.clear(); - } - - addApplied(pkg: string, target: string) { - this._applied.add(pkg, target); - } - - addAvailable(pkg: string, target: string) { - this._available.add(pkg, target); - } - - addNotApplied(pkg: string, target: string) { - this._notApplied.add(pkg, target); - } - - get applied(): TrackedHook[] { - return this._applied.serialize(); - } - - get available(): TrackedHook[] { - return this._available.serialize(); - } - - get notApplied(): TrackedHook[] { - return this._notApplied.serialize(); - } -} - -// Stores package names and names of functions of interest (targets) from that package [packageName0 -> [target0, ...], ...]. -// This structure is used to keep track of all functions seen during instrumentation and execution of the fuzzing run, -// to determine which hooks have been applied, are available, and have not been applied. -class HookTable { - hooks: Map> = new Map(); - - add(pkg: string, target: string) { - if (!this.hooks.has(pkg)) { - this.hooks.set(pkg, new Set()); - } - this.hooks.get(pkg)?.add(target); - } - - has(pkg: string, target: string) { - if (!this.hooks.has(pkg)) { - return false; - } - return this.hooks.get(pkg)?.has(target); - } - - serialize(): TrackedHook[] { - const result: TrackedHook[] = []; - for (const [pkg, targets] of [...this.hooks].sort()) { - for (const target of [...targets].sort()) { - result.push({ pkg: pkg, target: target }); - } - } - return result; - } - - clear() { - this.hooks.clear(); - } - - get length() { - let size = 0; - for (const targets of this.hooks.values()) { - size += targets.size; - } - return size; - } -} - -export function logHooks(hooks: Hook[]) { - hooks.forEach((hook) => { - if (process.env.JAZZER_DEBUG) { - console.log( - `DEBUG: Applied %s-hook in %s#%s`, - HookType[hook.type], - hook.pkg, - hook.target, - ); - } - }); -} - -export const hookTracker = new HookTracker(); - export enum HookType { Before, After, diff --git a/packages/hooking/index.ts b/packages/hooking/index.ts index ebd12c129..f2093d236 100644 --- a/packages/hooking/index.ts +++ b/packages/hooking/index.ts @@ -16,3 +16,4 @@ export * from "./hook"; export * from "./manager"; +export * from "./tracker"; diff --git a/packages/hooking/manager.ts b/packages/hooking/manager.ts index 82213cbe9..8acd51754 100644 --- a/packages/hooking/manager.ts +++ b/packages/hooking/manager.ts @@ -1,5 +1,5 @@ /* - * Copyright 2022 Code Intelligence GmbH + * Copyright 2023 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. @@ -21,34 +21,66 @@ import { HookFn, HookType, ReplaceHookFn, - logHooks, - hookTracker, } from "./hook"; -import { PluginTarget } from "@babel/core"; +import { hookTracker, logHooks } from "./tracker"; export class MatchingHooksResult { - public beforeHooks: Hook[] = []; - public replaceHooks: Hook[] = []; - public afterHooks: Hook[] = []; + private _beforeHooks: Hook[] = []; + private _replaceHooks: Hook[] = []; + private _afterHooks: Hook[] = []; + + get hooks() { + return this._beforeHooks.concat(this._afterHooks, this._replaceHooks); + } + + hasHooks() { + return ( + this.hasBeforeHooks() || this.hasReplaceHooks() || this.hasAfterHooks() + ); + } + + get beforeHooks(): Hook[] { + return this._beforeHooks; + } + + hasBeforeHooks() { + return this._beforeHooks.length !== 0; + } + + get replaceHooks(): Hook[] { + return this._replaceHooks; + } + + hasReplaceHooks() { + return this._replaceHooks.length !== 0; + } + + get afterHooks(): Hook[] { + return this._afterHooks; + } + + hasAfterHooks() { + return this._afterHooks.length !== 0; + } addHook(h: Hook) { switch (h.type) { case HookType.Before: - this.beforeHooks.push(h); + this._beforeHooks.push(h); break; case HookType.Replace: - this.replaceHooks.push(h); + this._replaceHooks.push(h); break; case HookType.After: - this.afterHooks.push(h); + this._afterHooks.push(h); break; } } verify() { - if (this.replaceHooks.length > 1) { + if (this._replaceHooks.length > 1) { throw new Error( - `For a given target function, one REPLACE hook can be configured. Found: ${this.replaceHooks.length}`, + `For a given target function, one REPLACE hook can be configured. Found: ${this._replaceHooks.length}`, ); } @@ -58,17 +90,17 @@ export class MatchingHooksResult { ) { throw new Error( `For a given target function, REPLACE hooks cannot be mixed up with BEFORE/AFTER hooks. Found ${ - this.replaceHooks.length + this._replaceHooks.length } REPLACE hooks and ${ - this.beforeHooks.length + this.afterHooks.length + this._beforeHooks.length + this._afterHooks.length } BEFORE/AFTER hooks`, ); } if (this.hasAfterHooks()) { if ( - !this.afterHooks.every((h) => h.async) && - !this.afterHooks.every((h) => !h.async) + !this._afterHooks.every((h) => h.async) && + !this._afterHooks.every((h) => !h.async) ) { throw new Error( "For a given target function, AFTER hooks have to be either all sync or all async.", @@ -76,36 +108,10 @@ export class MatchingHooksResult { } } } - - hooks() { - return this.beforeHooks.concat(this.afterHooks, this.replaceHooks); - } - - hasHooks() { - return ( - this.hasBeforeHooks() || this.hasReplaceHooks() || this.hasAfterHooks() - ); - } - - hasBeforeHooks() { - return this.beforeHooks.length !== 0; - } - - hasReplaceHooks() { - return this.replaceHooks.length !== 0; - } - - hasAfterHooks() { - return this.afterHooks.length !== 0; - } } export class HookManager { private _hooks: Hook[] = []; - private afterEachCallbacks: Array = []; - private beforeEachCallbacks: Array = []; - private dictionaries: Array = []; - private instrumentationPlugins: Array<() => PluginTarget> = []; registerHook( hookType: HookType, @@ -185,68 +191,10 @@ export class HookManager { ); } } - - registerAfterEachCallback(callback: Thunk) { - this.afterEachCallbacks.push(callback); - } - - registerBeforeEachCallback(callback: Thunk) { - this.beforeEachCallbacks.push(callback); - } - - addDictionary(libFuzzerDictionary: string[]) { - this.dictionaries.push(this.compileFuzzerDictionary(libFuzzerDictionary)); - } - - registerInstrumentationPlugin(plugin: () => PluginTarget) { - this.instrumentationPlugins.push(plugin); - } - - getDictionaries() { - return this.dictionaries; - } - - getInstrumentationPlugins() { - return this.instrumentationPlugins; - } - - runAfterEachCallbacks() { - for (const afterEachCallback of this.afterEachCallbacks) { - afterEachCallback(); - } - } - - runBeforeEachCallbacks() { - for (const beforeEachCallback of this.beforeEachCallbacks) { - beforeEachCallback(); - } - } - - private compileFuzzerDictionary(lines: string[]): string { - return lines.join("\n"); - } } -export function callSiteId(...additionalArguments: unknown[]): number { - const stackTrace = additionalArguments?.join(",") + new Error().stack; - if (!stackTrace || stackTrace.length === 0) { - return 0; - } - let hash = 0, - i, - chr; - for (i = 0; i < stackTrace.length; i++) { - chr = stackTrace.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return hash; -} - -type Thunk = () => void; - export const hookManager = new HookManager(); -// convenience functions to register hooks + export function registerBeforeHook( target: string, pkg: string, @@ -274,22 +222,6 @@ export function registerAfterHook( hookManager.registerHook(HookType.After, target, pkg, async, hookFn); } -export function registerAfterEachCallback(callback: Thunk) { - hookManager.registerAfterEachCallback(callback); -} - -export function registerBeforeEachCallback(callback: Thunk) { - hookManager.registerBeforeEachCallback(callback); -} - -export function addDictionary(...libFuzzerDictionary: string[]) { - hookManager.addDictionary(libFuzzerDictionary); -} - -export function registerInstrumentationPlugin(plugin: () => PluginTarget) { - hookManager.registerInstrumentationPlugin(plugin); -} - /** * Replaces a built-in function with a custom implementation while preserving * the original function for potential use within the replacement function. @@ -319,34 +251,22 @@ export async function hookBuiltInFunction(hook: Hook): Promise { hookTracker.addApplied(hook.pkg, hook.target); } -// Keep track of statements and expressions that should not be instrumented. -// This is necessary to avoid infinite recursion when instrumenting code. -class InstrumentationGuard { - private map: Map> = new Map(); - - /** - * Add a tag and a value to the guard. This can be used to look up if the value. - * The value will be stringified internally before being added to the guard. - * @example instrumentationGuard.add("AssignmentExpression", node.left); - */ - add(tag: string, value: unknown) { - if (!this.map.has(tag)) { - this.map.set(tag, new Set()); - } - this.map.get(tag)?.add(JSON.stringify(value)); +/** + * Returns a unique id for the call site of the function that called this function. + * @param additionalArguments additional arguments to be included in the hash + */ +export function callSiteId(...additionalArguments: unknown[]): number { + const stackTrace = additionalArguments?.join(",") + new Error().stack; + if (!stackTrace || stackTrace.length === 0) { + return 0; } - - /** - * Check if a value with a given tag exists in the guard. The value will be stringified internally before being checked. - * @example instrumentationGuard.has("AssignmentExpression", node.object); - */ - has(expression: string, value: unknown): boolean { - return ( - (this.map.has(expression) && - this.map.get(expression)?.has(JSON.stringify(value))) ?? - false - ); + let hash = 0, + i, + chr; + for (i = 0; i < stackTrace.length; i++) { + chr = stackTrace.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer } + return hash; } - -export const instrumentationGuard = new InstrumentationGuard(); diff --git a/packages/hooking/tracker.ts b/packages/hooking/tracker.ts new file mode 100644 index 000000000..2b65617f6 --- /dev/null +++ b/packages/hooking/tracker.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2023 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 { Hook, HookType } from "./hook"; + +export interface TrackedHook { + target: string; + pkg: string; +} + +/** + * Stores package names and names of functions of interest (targets) from that + * package [packageName0 -> [target0, ...], ...]. + * + * This structure is used to keep track of all functions seen during + * instrumentation and execution of the fuzzing run, to determine which hooks + * have been applied, are available, and have not been applied. + */ +class HookTable { + private _hooks: Map> = new Map(); + + add(pkg: string, target: string) { + if (!this._hooks.has(pkg)) { + this._hooks.set(pkg, new Set()); + } + this._hooks.get(pkg)?.add(target); + } + + has(pkg: string, target: string) { + if (!this._hooks.has(pkg)) { + return false; + } + return this._hooks.get(pkg)?.has(target); + } + + clear() { + this._hooks.clear(); + } + + get length() { + let size = 0; + for (const targets of this._hooks.values()) { + size += targets.size; + } + return size; + } + + serialize(): TrackedHook[] { + const result: TrackedHook[] = []; + for (const [pkg, targets] of [...this._hooks].sort()) { + for (const target of [...targets].sort()) { + result.push({ pkg: pkg, target: target }); + } + } + return result; + } +} + +/** + * HookTracker keeps track of hooks that were applied, are available, and were + * not applied. + * + * This is helpful when debugging custom hooks and bug detectors. + */ +class HookTracker { + private _applied = new HookTable(); + private _available = new HookTable(); + private _notApplied = new HookTable(); + + print() { + console.log("DEBUG: [Hook] Summary:"); + console.log("DEBUG: [Hook] Not applied: " + this._notApplied.length); + this._notApplied.serialize().forEach((hook) => { + console.log(`DEBUG: [Hook] not applied: ${hook.pkg} -> ${hook.target}`); + }); + console.log("DEBUG: [Hook] Applied: " + this._applied.length); + this._applied.serialize().forEach((hook) => { + console.log(`DEBUG: [Hook] applied: ${hook.pkg} -> ${hook.target}`); + }); + console.log("DEBUG: [Hook] Available: " + this._available.length); + this._available.serialize().forEach((hook) => { + console.log(`DEBUG: [Hook] available: ${hook.pkg} -> ${hook.target}`); + }); + } + + categorizeUnknown(requestedHooks: Hook[]): this { + requestedHooks.forEach((hook) => { + if ( + !this._applied.has(hook.pkg, hook.target) && + !this._available.has(hook.pkg, hook.target) + ) { + this.addNotApplied(hook.pkg, hook.target); + } + }); + return this; + } + + clear() { + this._applied.clear(); + this._notApplied.clear(); + this._available.clear(); + } + + addApplied(pkg: string, target: string) { + this._applied.add(pkg, target); + } + + addAvailable(pkg: string, target: string) { + this._available.add(pkg, target); + } + + addNotApplied(pkg: string, target: string) { + this._notApplied.add(pkg, target); + } + + get applied(): TrackedHook[] { + return this._applied.serialize(); + } + + get available(): TrackedHook[] { + return this._available.serialize(); + } + + get notApplied(): TrackedHook[] { + return this._notApplied.serialize(); + } +} + +export const hookTracker = new HookTracker(); + +export function logHooks(hooks: Hook[]) { + hooks.forEach((hook) => { + if (process.env.JAZZER_DEBUG) { + console.log( + `DEBUG: Applied %s-hook in %s#%s`, + HookType[hook.type], + hook.pkg, + hook.target, + ); + } + }); +} diff --git a/packages/instrumentor/guard.ts b/packages/instrumentor/guard.ts new file mode 100644 index 000000000..ee4270b08 --- /dev/null +++ b/packages/instrumentor/guard.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2023 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. + */ + +// Keep track of statements and expressions that should not be instrumented. +// This is necessary to avoid infinite recursion when instrumenting code. +export class InstrumentationGuard { + private map: Map> = new Map(); + + /** + * Add a tag and a value to the guard. This can be used to look up if the value. + * The value will be stringified internally before being added to the guard. + * @example instrumentationGuard.add("AssignmentExpression", node.left); + */ + add(tag: string, value: unknown) { + if (!this.map.has(tag)) { + this.map.set(tag, new Set()); + } + this.map.get(tag)?.add(JSON.stringify(value)); + } + + /** + * Check if a value with a given tag exists in the guard. The value will be stringified internally before being checked. + * @example instrumentationGuard.has("AssignmentExpression", node.object); + */ + has(expression: string, value: unknown): boolean { + return ( + (this.map.has(expression) && + this.map.get(expression)?.has(JSON.stringify(value))) ?? + false + ); + } +} + +export const instrumentationGuard = new InstrumentationGuard(); diff --git a/packages/instrumentor/instrument.ts b/packages/instrumentor/instrument.ts index a17d0c58e..60211e80e 100644 --- a/packages/instrumentor/instrument.ts +++ b/packages/instrumentor/instrument.ts @@ -22,6 +22,7 @@ import { } from "@babel/core"; import { hookRequire, TransformerOptions } from "istanbul-lib-hook"; import { hookManager } from "@jazzer.js/hooking"; +import { instrumentationPlugins } from "./plugin"; import { codeCoverage } from "./plugins/codeCoverage"; import { sourceCodeCoverage } from "./plugins/sourceCodeCoverage"; import { compareHooks } from "./plugins/compareHooks"; @@ -33,6 +34,8 @@ import { toRawSourceMap, } from "./SourceMapRegistry"; +export { instrumentationGuard } from "./guard"; +export { registerInstrumentationPlugin } from "./plugin"; export { EdgeIdStrategy, FileSyncIdStrategy, @@ -74,7 +77,7 @@ export class Instrumentor { const shouldInstrumentFile = this.shouldInstrumentForFuzzing(filename); if (shouldInstrumentFile) { transformations.push( - ...hookManager.getInstrumentationPlugins(), + ...instrumentationPlugins.plugins, codeCoverage(this.idStrategy), compareHooks, ); diff --git a/packages/instrumentor/plugin.ts b/packages/instrumentor/plugin.ts new file mode 100644 index 000000000..4ce9e90cc --- /dev/null +++ b/packages/instrumentor/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 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 { PluginTarget } from "@babel/core"; + +/** + * Instrumentation plugins are can be used to add additional instrumentation by + * bug detectors. + */ +export class InstrumentationPlugins { + private _plugins: Array<() => PluginTarget> = []; + + registerPlugin(plugin: () => PluginTarget) { + this._plugins.push(plugin); + } + + get plugins() { + return this._plugins; + } +} + +export const instrumentationPlugins = new InstrumentationPlugins(); + +export function registerInstrumentationPlugin(plugin: () => PluginTarget) { + instrumentationPlugins.registerPlugin(plugin); +} diff --git a/packages/instrumentor/plugins/functionHooks.ts b/packages/instrumentor/plugins/functionHooks.ts index 68394ec1d..e5ab3e80e 100644 --- a/packages/instrumentor/plugins/functionHooks.ts +++ b/packages/instrumentor/plugins/functionHooks.ts @@ -60,7 +60,7 @@ function applyHooks( return false; } - for (const hook of matchedHooks.hooks()) { + for (const hook of matchedHooks.hooks) { hookTracker.addApplied(hook.pkg, hook.target); } @@ -103,7 +103,7 @@ function applyHooks( addBeforeHooks(functionNode as FunctionWithBlockBody, matchedHooks); } - logHooks(matchedHooks.hooks()); + logHooks(matchedHooks.hooks); return true; } diff --git a/tests/bug-detectors/.gitignore b/tests/bug-detectors/.gitignore new file mode 100644 index 000000000..6546ae9ad --- /dev/null +++ b/tests/bug-detectors/.gitignore @@ -0,0 +1,2 @@ +.jazzerjsrc.json +FRIENDLY From aaec5f48ad4ecaf5f4d41fcd5a8969bf50951c5a Mon Sep 17 00:00:00 2001 From: Norbert Schneider Date: Tue, 18 Jul 2023 09:45:41 +0200 Subject: [PATCH 4/5] Split up core module functions into multiple files The core source code file contained functions for varying topics and with different abstraction levels. For better understanding and cohesion these functions were extracted into topic specific files, only exporting really necessary internals. --- .gitignore | 2 - fuzztests/core.fuzz.js | 34 -- packages/core/cli.ts | 3 +- packages/core/core.ts | 302 ++---------------- packages/core/dictionary.test.ts | 90 ++++++ packages/core/dictionary.ts | 41 ++- packages/core/finding.ts | 62 ++++ packages/core/options.ts | 129 ++++++++ packages/core/{core.test.ts => utils.test.ts} | 2 +- packages/core/utils.ts | 41 +++ packages/hooking/manager.ts | 34 +- .../bug-detectors/prototype-pollution.test.js | 55 +--- .../prototype-pollution/01-UserDictionary.txt | 2 - .../prototype-pollution/02-UserDictionary.txt | 3 - 14 files changed, 427 insertions(+), 373 deletions(-) delete mode 100644 fuzztests/core.fuzz.js create mode 100644 packages/core/dictionary.test.ts create mode 100644 packages/core/options.ts rename packages/core/{core.test.ts => utils.test.ts} (97%) create mode 100644 packages/core/utils.ts delete mode 100644 tests/bug-detectors/prototype-pollution/01-UserDictionary.txt delete mode 100644 tests/bug-detectors/prototype-pollution/02-UserDictionary.txt diff --git a/.gitignore b/.gitignore index b0410ba28..fec9b17a5 100644 --- a/.gitignore +++ b/.gitignore @@ -51,5 +51,3 @@ node_modules/ # Output of 'npm pack' *.tgz - -.JazzerJs-merged-dictionaries diff --git a/fuzztests/core.fuzz.js b/fuzztests/core.fuzz.js deleted file mode 100644 index 511e73205..000000000 --- a/fuzztests/core.fuzz.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2023 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 { ensureFilepath } = require("@jazzer.js/core"); - -const cwd = process.cwd(); - -describe("core", () => { - it.fuzz("ensureFilepath", (data) => { - try { - let filepath = ensureFilepath(data.toString()); - expect(filepath).toMatch(/.*\.(js|mjs|cjs)$/); - expect(filepath).toMatch(/^file:\/\/.*/); - expect(filepath.substring(7)).toContain(cwd); - } catch (e) { - if (e.matcherResult === undefined) { - expect(e.message).toContain("Empty filepath provided"); - } - } - }); -}); diff --git a/packages/core/cli.ts b/packages/core/cli.ts index adb4dbea2..f69aea0de 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -16,7 +16,8 @@ */ import yargs, { Argv } from "yargs"; -import { startFuzzing, ensureFilepath } from "./core"; +import { startFuzzing } from "./core"; +import { ensureFilepath } from "./utils"; yargs(process.argv.slice(2)) .scriptName("jazzer") diff --git a/packages/core/core.ts b/packages/core/core.ts index 1d21fe1f3..561932f4c 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import path from "path"; -import { builtinModules } from "module"; -import * as process from "process"; import * as tmp from "tmp"; import * as fs from "fs"; @@ -25,7 +23,7 @@ import * as reports from "istanbul-reports"; import * as fuzzer from "@jazzer.js/fuzzer"; import * as hooking from "@jazzer.js/hooking"; -import { clearFirstFinding, Finding, getFirstFinding } from "./finding"; +import { clearFirstFinding, getFirstFinding, printFinding } from "./finding"; import { FileSyncIdStrategy, Instrumentor, @@ -33,7 +31,8 @@ import { registerInstrumentor, } from "@jazzer.js/instrumentor"; import { callbacks } from "./callback"; -import { dictionaries } from "./dictionary"; +import { ensureFilepath, importModule } from "./utils"; +import { buildFuzzerOption } from "./options"; // Remove temporary files on exit tmp.setGracefulCleanup(); @@ -65,10 +64,6 @@ export interface Options { verbose?: boolean; } -interface FuzzModule { - [fuzzEntryPoint: string]: fuzzer.FuzzTarget; -} - /* eslint no-var: 0 */ declare global { var Fuzzer: fuzzer.Fuzzer; @@ -115,11 +110,18 @@ export async function initFuzzing(options: Options): Promise { await Promise.all(options.customHooks.map(ensureFilepath).map(importModule)); - // Built-in functions cannot be hooked by the instrumentor, so we manually hook them here. - await hookBuiltInFunctions(hooking.hookManager); + await hooking.hookManager.finalizeHooks(); +} + +function registerGlobals(options: Options) { + globalThis.Fuzzer = fuzzer.fuzzer; + globalThis.HookManager = hooking.hookManager; + globalThis.options = options; } // Filters out disabled bug detectors and prepares all the others for dynamic import. +// This functionality belongs to the bug-detector module but no dependency from +// core to bug-detectors is allowed. function getFilteredBugDetectorPaths( bugDetectorsDirectory: string, disableBugDetectors: string[], @@ -158,30 +160,6 @@ function getFilteredBugDetectorPaths( ); } -// Built-in functions cannot be hooked by the instrumentor. We hook them by overwriting them at the module level. -async function hookBuiltInFunctions(hookManager: hooking.HookManager) { - for (const builtinModule of builtinModules) { - for (const hook of hookManager.getMatchingHooks(builtinModule)) { - try { - await hooking.hookBuiltInFunction(hook); - } catch (e) { - if (process.env.JAZZER_DEBUG) { - console.log( - "DEBUG: [Hook] Error when trying to hook the built-in function: " + - e, - ); - } - } - } - } -} - -export function registerGlobals(options: Options) { - globalThis.Fuzzer = fuzzer.fuzzer; - globalThis.HookManager = hooking.hookManager; - globalThis.options = options; -} - export async function startFuzzing(options: Options) { await initFuzzing(options); const fuzzFn = await loadFuzzFunction(options); @@ -208,14 +186,6 @@ export async function startFuzzing(options: Options) { ); } -function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { - fuzzerOptions.slice(1).forEach((element) => { - if (element.length > 0 && element[0] != "-") { - console.error("INFO: using inputs from:", element); - } - }); -} - export async function startFuzzingNoInit( fuzzFn: fuzzer.FuzzTarget, options: Options, @@ -235,15 +205,8 @@ export async function startFuzzingNoInit( ); }; - const fuzzerOptions = buildFuzzerOptions(options); - logInfoAboutFuzzerOptions(fuzzerOptions); - // in verbose mode print the configuration - if (process.env.JAZZER_DEBUG) { - console.debug("DEBUG: [core] Jazzer.js initial arguments: "); - console.debug(options); - console.debug("DEBUG: [core] Jazzer.js actually used fuzzer arguments: "); - console.debug(fuzzerOptions); - } + const fuzzerOptions = buildFuzzerOption(options); + if (options.sync) { return Promise.resolve().then(() => Fuzzer.startFuzzing( @@ -262,59 +225,6 @@ export async function startFuzzingNoInit( } } -function prepareLibFuzzerArg0(fuzzerOptions: string[]): string { - // When we run in a libFuzzer mode that spawns subprocesses, we create a wrapper script - // that can be used as libFuzzer's argv[0]. In the fork mode, the main libFuzzer process - // uses argv[0] to spawn further processes that perform the actual fuzzing. - const libFuzzerSpawnsProcess = fuzzerOptions.some( - (flag) => - flag.startsWith("-fork=") || - flag.startsWith("-jobs=") || - flag.startsWith("-merge="), - ); - - if (!libFuzzerSpawnsProcess) { - // Return a fake argv[0] to start the fuzzer if libFuzzer does not spawn new processes. - return "unused_arg0_report_a_bug_if_you_see_this"; - } else { - // Create a wrapper script and return its path. - return createWrapperScript(fuzzerOptions); - } -} - -function createWrapperScript(fuzzerOptions: string[]) { - const jazzerArgs = process.argv.filter( - (arg) => arg !== "--" && fuzzerOptions.indexOf(arg) === -1, - ); - - if (jazzerArgs.indexOf("--id_sync_file") === -1) { - const idSyncFile = tmp.fileSync({ - mode: 0o600, - prefix: "jazzer.js", - postfix: "idSync", - }); - jazzerArgs.push("--id_sync_file", idSyncFile.name); - fs.closeSync(idSyncFile.fd); - } - - const isWindows = process.platform === "win32"; - - const scriptContent = `${isWindows ? "@echo off" : "#!/usr/bin/env sh"} -cd "${process.cwd()}" -${jazzerArgs.map((s) => '"' + s + '"').join(" ")} -- ${isWindows ? "%*" : "$@"} -`; - - const scriptTempFile = tmp.fileSync({ - mode: 0o700, - prefix: "jazzer.js", - postfix: "libfuzzer" + (isWindows ? ".bat" : ".sh"), - }); - fs.writeFileSync(scriptTempFile.name, scriptContent); - fs.closeSync(scriptTempFile.fd); - - return scriptTempFile.name; -} - function stopFuzzing( err: unknown, expectedErrors: string[], @@ -361,7 +271,7 @@ function stopFuzzing( console.error(`INFO: Received expected error "${name}".`); stopFuzzing(ERROR_EXPECTED_CODE); } else { - printError(err); + printFinding(err); console.error( `ERROR: Received error "${name}" is not in expected errors [${expectedErrors}].`, ); @@ -372,7 +282,7 @@ function stopFuzzing( // Error found, but no specific one expected. This case is used for normal // fuzzing runs, so no dedicated exit code is given to the stop fuzzing function. - printError(err); + printFinding(err); stopFuzzing(); } @@ -390,142 +300,6 @@ function errorName(error: unknown): string { } } -function printError(error: unknown) { - let errorMessage = `==${process.pid}== `; - if (!(error instanceof Finding)) { - errorMessage += "Uncaught Exception: Jazzer.js: "; - } - - if (error instanceof Error) { - errorMessage += error.message; - console.log(errorMessage); - if (error.stack) { - console.log(cleanErrorStack(error)); - } - } else if (typeof error === "string" || error instanceof String) { - errorMessage += error; - console.log(errorMessage); - } else { - errorMessage += "unknown"; - console.log(errorMessage); - } -} - -function cleanErrorStack(error: Error): string { - if (error.stack === undefined) return ""; - - // This cleans up the stack of a finding. The changes are independent of each other, since a finding can be - // thrown from the hooking library, by the custom hooks, or by the fuzz target. - if (error instanceof Finding) { - // Remove the message from the stack trace. Also remove the subsequent line of the remaining stack trace that - // always contains `reportFinding()`, which is not relevant for the user. - error.stack = error.stack - ?.replace(`Error: ${error.message}\n`, "") - .replace(/.*\n/, ""); - - // Remove all lines up to and including the line that mentions the hooking library from the stack trace of a - // finding. - const stack = error.stack.split("\n"); - const index = stack.findIndex((line) => - line.includes("jazzer.js/packages/hooking/manager"), - ); - if (index !== undefined && index >= 0) { - error.stack = stack.slice(index + 1).join("\n"); - } - - // also delete all lines that mention "jazzer.js/packages/" - error.stack = error.stack.replace(/.*jazzer.js\/packages\/.*\n/g, ""); - } - - const result: string[] = []; - for (const line of error.stack.split("\n")) { - if (line.includes("jazzer.js/packages/core/core.ts")) { - break; - } - result.push(line); - } - return result.join("\n"); -} - -function buildFuzzerOptions(options: Options): string[] { - if (!options || !options.fuzzerOptions) { - return []; - } - - let opts = options.fuzzerOptions; - if (options.mode === "regression") { - // the last provided option takes precedence - opts = opts.concat("-runs=0"); - } - - if (options.timeout <= 0) { - throw new Error("timeout must be > 0"); - } - const inSeconds = Math.ceil(options.timeout / 1000); - opts = opts.concat(`-timeout=${inSeconds}`); - - // libFuzzer has to ignore SIGINT and SIGTERM, as it interferes - // with the Node.js signal handling. - opts = opts.concat("-handle_int=0", "-handle_term=0"); - - // Dictionary handling. This diverges from the libfuzzer behavior, which allows only one dictionary (the last one). - // We merge all dictionaries into one and pass that to libfuzzer. - let shouldUseDictionaries = false; - const mergedDictionary = `.JazzerJs-merged-dictionaries`; - let dictionary = ""; - - // Extract dictionaries from bug detectors. - for (const dict of dictionaries.dictionary) { - // Make an empty dictionary file. - if (!shouldUseDictionaries) { - shouldUseDictionaries = true; - } - // Append the contents of dict to the .jazzer-merged-dictionaries file. - dictionary = dictionary.concat(dict); - } - - // Merge all dictionaries into one: .jazzer-all-dictionaries. - for (const option of options.fuzzerOptions) { - if (option.startsWith("-dict=")) { - const dict = option.substring(6); - // if the dictionary is the same as the merged dictionary, skip it. - if (dict === mergedDictionary) { - continue; - } - // Make an empty dictionary file. - if (!shouldUseDictionaries) { - shouldUseDictionaries = true; - } - - // Preserve the file name in a comment before merging dictionary contents. - dictionary = dictionary.concat(`\n# ${dict}:\n`); - dictionary = dictionary.concat(fs.readFileSync(dict).toString()); - // Drop the dictionary from the list of options. - opts = opts.filter((o) => o !== option); - } - } - - if (shouldUseDictionaries) { - // Add a comment to the top of the dictionary file. - dictionary = - "# This file was automatically generated. Do not edit.\n" + dictionary; - // Check if the merged dictionary already exists and has the same contents. - if (fs.existsSync(mergedDictionary)) { - const existingDictionary = fs.readFileSync(mergedDictionary).toString(); - // Overwrite only if the dictionary contents differ. - if (existingDictionary !== dictionary) { - fs.writeFileSync(mergedDictionary, dictionary); - } - } else { - // Otherwise, create the file. - fs.writeFileSync(mergedDictionary, dictionary); - } - opts = opts.concat(`-dict=${mergedDictionary}`); - } - - return [prepareLibFuzzerArg0(opts), ...opts]; -} - async function loadFuzzFunction(options: Options): Promise { const fuzzTarget = await importModule(options.fuzzTarget); if (!fuzzTarget) { @@ -549,6 +323,18 @@ async function loadFuzzFunction(options: Options): Promise { export function wrapFuzzFunctionForBugDetection( originalFuzzFn: fuzzer.FuzzTarget, ): fuzzer.FuzzTarget { + function throwIfError(fuzzTargetError?: unknown): undefined | never { + const error = getFirstFinding(); + if (error !== undefined) { + // The `firstFinding` is a global variable: we need to clear it after each fuzzing iteration. + clearFirstFinding(); + throw error; + } else if (fuzzTargetError) { + throw fuzzTargetError; + } + return undefined; + } + if (originalFuzzFn.length === 1) { return (data: Buffer): void | Promise => { let fuzzTargetError: unknown; @@ -605,38 +391,6 @@ export function wrapFuzzFunctionForBugDetection( } } -function throwIfError(fuzzTargetError?: unknown) { - const error = getFirstFinding(); - if (error !== undefined) { - // The `firstFinding` is a global variable: we need to clear it after each fuzzing iteration. - clearFirstFinding(); - throw error; - } else if (fuzzTargetError) { - throw fuzzTargetError; - } - return undefined; -} - -async function importModule(name: string): Promise { - return import(name); -} - -export function ensureFilepath(filePath: string): string { - if (!filePath) { - throw Error("Empty filepath provided"); - } - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.join(process.cwd(), filePath); - - // file: schema is required on Windows - const fullPath = "file://" + absolutePath; - return [".js", ".mjs", ".cjs"].some((suffix) => fullPath.endsWith(suffix)) - ? fullPath - : fullPath + ".js"; -} - // Export public API from within core module for easy access. export * from "./api"; export { FuzzedDataProvider } from "./FuzzedDataProvider"; diff --git a/packages/core/dictionary.test.ts b/packages/core/dictionary.test.ts new file mode 100644 index 000000000..509e8c42c --- /dev/null +++ b/packages/core/dictionary.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2023 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 fs from "fs"; +import { addDictionary, useDictionaryByParams } from "./dictionary"; + +const tmp = require("tmp"); + +// Cleanup created files on exit +tmp.setGracefulCleanup(); + +describe("Dictionary", () => { + it("use explicit dictionary", () => { + const content = ` +# comment +"01234567890-Test" +`; + const filename = writeDict(content); + + const params = useDictionaryByParams([`-dict=${filename}`]); + + const tempDictionary = params[params.length - 1].substring(6); + expect(tempDictionary).not.toMatch(`^-dict=${filename}$`); + const tempDictionaryContent = fs.readFileSync(tempDictionary).toString(); + expect(tempDictionaryContent).toMatch(content); + }); + + it("combine two explicit dictionaries", () => { + const content1 = ` +# comment 1 +"01234567890-Test" +`; + const filename1 = writeDict(content1); + const content2 = ` +# comment 2 +"abcdef-Test" +`; + const filename2 = writeDict(content2); + + const params = useDictionaryByParams([ + `-dict=${filename1}`, + `-dict=${filename2}`, + ]); + + const tempDictionary = params[params.length - 1].substring(6); + const tempDictionaryContent = fs.readFileSync(tempDictionary).toString(); + expect(tempDictionaryContent).toContain(content1); + expect(tempDictionaryContent).toContain(content2); + }); + + it("combines explicit dictionary with programmatic one", () => { + const content = ` +# comment +"01234567890-Test" +`; + const filename = writeDict(content); + const dictLines = ["abcdef-Test", "ghijkl-Test"]; + + addDictionary(...dictLines); + const params = useDictionaryByParams([`-dict=${filename}`]); + + const tempDictionary = params[params.length - 1].substring(6); + const tempDictionaryContent = fs.readFileSync(tempDictionary).toString(); + expect(tempDictionaryContent).toContain(content); + expect(tempDictionaryContent).toContain(dictLines[0]); + expect(tempDictionaryContent).toContain(dictLines[1]); + }); +}); + +function writeDict(content: string) { + const dict = tmp.fileSync({ + mode: 0o700, + prefix: "jazzer.js-test", + postfix: "dict", + }); + fs.writeFileSync(dict.name, content); + return dict.name; +} diff --git a/packages/core/dictionary.ts b/packages/core/dictionary.ts index 2a6573e30..0388e7033 100644 --- a/packages/core/dictionary.ts +++ b/packages/core/dictionary.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import fs from "fs"; +import tmp from "tmp"; + /** * Dictionaries can be used to provide additional mutation suggestions to the * fuzzer. @@ -26,12 +29,46 @@ export class Dictionaries { } addDictionary(dictionary: string[]) { - this._dictionary.push(dictionary.join("\n")); + this._dictionary.push(...dictionary); } } -export const dictionaries = new Dictionaries(); +const dictionaries = new Dictionaries(); export function addDictionary(...dictionary: string[]) { dictionaries.addDictionary(dictionary); } + +export function useDictionaryByParams(options: string[]): string[] { + const opts = [...options]; + const dictionary = Array.from(dictionaries.dictionary); + + // This diverges from the libFuzzer behavior, which allows only one dictionary (the last one). + // We merge all dictionaries into one and pass that to libfuzzer. + for (const option of options) { + if (option.startsWith("-dict=")) { + const dict = option.substring(6); + // Preserve the filename in a comment before merging dictionary contents. + dictionary.push(`\n# ${dict}:`); + dictionary.push(fs.readFileSync(dict).toString()); + } + } + + if (dictionary.length > 0) { + // Add a comment to the top of the dictionary file. + dictionary.unshift("# This file was automatically generated. Do not edit."); + const content = dictionary.join("\n"); + + // Use a temporary dictionary file to pass in the merged dictionaries. + const dictFile = tmp.fileSync({ + mode: 0o700, + prefix: "jazzer.js", + postfix: "dict", + }); + fs.writeFileSync(dictFile.name, content); + fs.closeSync(dictFile.fd); + + opts.push("-dict=" + dictFile.name); + } + return opts; +} diff --git a/packages/core/finding.ts b/packages/core/finding.ts index 9fc9be2b2..2dd511038 100644 --- a/packages/core/finding.ts +++ b/packages/core/finding.ts @@ -15,6 +15,8 @@ * */ +import process from "process"; + export class Finding extends Error {} // The first finding reported by any bug detector will be saved here. @@ -46,3 +48,63 @@ export function reportFinding(findingMessage: string): void | never { firstFinding = new Finding(findingMessage); throw firstFinding; } + +/** + * Prints a finding, or more generally some kind of error, to stdout. + */ +export function printFinding(error: unknown) { + let errorMessage = `==${process.pid}== `; + if (!(error instanceof Finding)) { + errorMessage += "Uncaught Exception: Jazzer.js: "; + } + + if (error instanceof Error) { + errorMessage += error.message; + console.log(errorMessage); + if (error.stack) { + console.log(cleanErrorStack(error)); + } + } else if (typeof error === "string" || error instanceof String) { + errorMessage += error; + console.log(errorMessage); + } else { + errorMessage += "unknown"; + console.log(errorMessage); + } +} + +function cleanErrorStack(error: Error): string { + if (error.stack === undefined) return ""; + + // This cleans up the stack of a finding. The changes are independent of each other, since a finding can be + // thrown from the hooking library, by the custom hooks, or by the fuzz target. + if (error instanceof Finding) { + // Remove the message from the stack trace. Also remove the subsequent line of the remaining stack trace that + // always contains `reportFinding()`, which is not relevant for the user. + error.stack = error.stack + ?.replace(`Error: ${error.message}\n`, "") + .replace(/.*\n/, ""); + + // Remove all lines up to and including the line that mentions the hooking library from the stack trace of a + // finding. + const stack = error.stack.split("\n"); + const index = stack.findIndex((line) => + line.includes("jazzer.js/packages/hooking/manager"), + ); + if (index !== undefined && index >= 0) { + error.stack = stack.slice(index + 1).join("\n"); + } + + // also delete all lines that mention "jazzer.js/packages/" + error.stack = error.stack.replace(/.*jazzer.js\/packages\/.*\n/g, ""); + } + + const result: string[] = []; + for (const line of error.stack.split("\n")) { + if (line.includes("jazzer.js/packages/core/core.ts")) { + break; + } + result.push(line); + } + return result.join("\n"); +} diff --git a/packages/core/options.ts b/packages/core/options.ts new file mode 100644 index 000000000..8312d0eb3 --- /dev/null +++ b/packages/core/options.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2023 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 * as tmp from "tmp"; +import fs from "fs"; +import { Options } from "./core"; +import { useDictionaryByParams } from "./dictionary"; + +export function buildFuzzerOption(options: Options) { + if (process.env.JAZZER_DEBUG) { + console.debug("DEBUG: [core] Jazzer.js initial fuzzer arguments: "); + console.debug(options); + } + + let params: string[] = []; + params = optionDependentParams(options, params); + params = forkedExecutionParams(params); + params = useDictionaryByParams(params); + + // libFuzzer has to ignore SIGINT and SIGTERM, as it interferes + // with the Node.js signal handling. + params = params.concat("-handle_int=0", "-handle_term=0"); + + if (process.env.JAZZER_DEBUG) { + console.debug("DEBUG: [core] Jazzer.js actually used fuzzer arguments: "); + console.debug(params); + } + logInfoAboutFuzzerOptions(params); + return params; +} + +function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { + fuzzerOptions.slice(1).forEach((element) => { + if (element.length > 0 && element[0] != "-") { + console.error("INFO: using inputs from:", element); + } + }); +} + +function optionDependentParams(options: Options, params: string[]): string[] { + if (!options || !options.fuzzerOptions) { + return params; + } + + let opts = options.fuzzerOptions; + if (options.mode === "regression") { + // The last provided option takes precedence + opts = opts.concat("-runs=0"); + } + + if (options.timeout <= 0) { + throw new Error("timeout must be > 0"); + } + const inSeconds = Math.ceil(options.timeout / 1000); + opts = opts.concat(`-timeout=${inSeconds}`); + + return opts; +} + +function forkedExecutionParams(params: string[]): string[] { + return [prepareLibFuzzerArg0(params), ...params]; +} + +function prepareLibFuzzerArg0(fuzzerOptions: string[]): string { + // When we run in a libFuzzer mode that spawns subprocesses, we create a wrapper script + // that can be used as libFuzzer's argv[0]. In the fork mode, the main libFuzzer process + // uses argv[0] to spawn further processes that perform the actual fuzzing. + const libFuzzerSpawnsProcess = fuzzerOptions.some( + (flag) => + (flag.startsWith("-fork=") && !flag.startsWith("-fork=0")) || + (flag.startsWith("-jobs=") && !flag.startsWith("-jobs=0")) || + (flag.startsWith("-merge=") && !flag.startsWith("-merge=0")), + ); + + if (!libFuzzerSpawnsProcess) { + // Return a fake argv[0] to start the fuzzer if libFuzzer does not spawn new processes. + return "unused_arg0_report_a_bug_if_you_see_this"; + } else { + // Create a wrapper script and return its path. + return createWrapperScript(fuzzerOptions); + } +} + +function createWrapperScript(fuzzerOptions: string[]) { + const jazzerArgs = process.argv.filter( + (arg) => arg !== "--" && fuzzerOptions.indexOf(arg) === -1, + ); + + if (jazzerArgs.indexOf("--id_sync_file") === -1) { + const idSyncFile = tmp.fileSync({ + mode: 0o600, + prefix: "jazzer.js", + postfix: "idSync", + }); + jazzerArgs.push("--id_sync_file", idSyncFile.name); + fs.closeSync(idSyncFile.fd); + } + + const isWindows = process.platform === "win32"; + + const scriptContent = `${isWindows ? "@echo off" : "#!/usr/bin/env sh"} +cd "${process.cwd()}" +${jazzerArgs.map((s) => '"' + s + '"').join(" ")} -- ${isWindows ? "%*" : "$@"} +`; + + const scriptTempFile = tmp.fileSync({ + mode: 0o700, + prefix: "jazzer.js", + postfix: "libfuzzer" + (isWindows ? ".bat" : ".sh"), + }); + fs.writeFileSync(scriptTempFile.name, scriptContent); + fs.closeSync(scriptTempFile.fd); + + return scriptTempFile.name; +} diff --git a/packages/core/core.test.ts b/packages/core/utils.test.ts similarity index 97% rename from packages/core/core.test.ts rename to packages/core/utils.test.ts index a493bd4f6..23a03a49e 100644 --- a/packages/core/core.test.ts +++ b/packages/core/utils.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ensureFilepath } from "./core"; +import { ensureFilepath } from "./utils"; import path from "path"; diff --git a/packages/core/utils.ts b/packages/core/utils.ts new file mode 100644 index 000000000..d65cd6e55 --- /dev/null +++ b/packages/core/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2023 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 path from "path"; +import process from "process"; +import * as fuzzer from "@jazzer.js/fuzzer"; + +export interface FuzzModule { + [fuzzEntryPoint: string]: fuzzer.FuzzTarget; +} + +export async function importModule(name: string): Promise { + return import(name); +} + +export function ensureFilepath(filePath: string): string { + if (!filePath || filePath.length === 0) { + throw Error("Empty filepath provided"); + } + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(process.cwd(), filePath); + // file: schema is required on Windows + const fullPath = "file://" + absolutePath; + return [".js", ".mjs", ".cjs"].some((suffix) => fullPath.endsWith(suffix)) + ? fullPath + : fullPath + ".js"; +} diff --git a/packages/hooking/manager.ts b/packages/hooking/manager.ts index 8acd51754..7a9aaa3a1 100644 --- a/packages/hooking/manager.ts +++ b/packages/hooking/manager.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { builtinModules } from "module"; import { AfterHookFn, BeforeHookFn, @@ -113,6 +114,35 @@ export class MatchingHooksResult { export class HookManager { private _hooks: Hook[] = []; + /** + * Finalizes the registration of new hooks and performs necessary + * initialization steps for the hooks to work. This method must be called + * after all hooks have been registered. + */ + async finalizeHooks() { + // Built-in functions cannot be hooked by the instrumentor, so that is + // explicitly done here instead. + // Loading build-in modules is asynchronous, so we need to wait, which + // is not possible in the instrumentor. + for (const builtinModule of builtinModules) { + const matchedHooks = this._hooks.filter((hook) => + builtinModule.includes(hook.pkg), + ); + for (const hook of matchedHooks) { + try { + await hookBuiltInFunction(hook); + } catch (e) { + if (process.env.JAZZER_DEBUG) { + console.log( + "DEBUG: [Hook] Error when trying to hook the built-in function: " + + e, + ); + } + } + } + } + } + registerHook( hookType: HookType, target: string, @@ -159,10 +189,6 @@ export class HookManager { ); } - getMatchingHooks(filepath: string): Hook[] { - return this._hooks.filter((hook) => filepath.includes(hook.pkg)); - } - callHook( id: number, thisPtr: object, diff --git a/tests/bug-detectors/prototype-pollution.test.js b/tests/bug-detectors/prototype-pollution.test.js index 9ebb4769f..96a06d1fe 100644 --- a/tests/bug-detectors/prototype-pollution.test.js +++ b/tests/bug-detectors/prototype-pollution.test.js @@ -15,9 +15,11 @@ */ const path = require("path"); -const { readFileSync } = require("fs"); -const { FuzzTestBuilder, FuzzingExitCode } = require("../helpers.js"); -const { JestRegressionExitCode } = require("../helpers"); +const { + FuzzTestBuilder, + FuzzingExitCode, + JestRegressionExitCode, +} = require("../helpers.js"); describe("Prototype Pollution", () => { const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); @@ -246,53 +248,6 @@ describe("Prototype Pollution", () => { // }); }); -describe("Prototype Pollution Dictionary Tests", () => { - const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); - - it("One user dictionary", () => { - const fuzzTest = new FuzzTestBuilder() - .dictionaries(["01-UserDictionary.txt"]) - .dir(bugDetectorDirectory) - .fuzzEntryPoint("DictionaryTest") - .sync(true) - .verbose(true) - .build(); - fuzzTest.execute(); - // Check if the contents of the user dictionary are in the merged dictionary - const userDictionary = readFileSync( - path.join(bugDetectorDirectory, "01-UserDictionary.txt"), - ); - const mergedDictionary = readFileSync( - path.join(bugDetectorDirectory, ".JazzerJs-merged-dictionaries"), - ); - expect(mergedDictionary.toString()).toContain(userDictionary.toString()); - }); - - it("Two user dictionaries", () => { - const fuzzTest = new FuzzTestBuilder() - .dictionaries(["01-UserDictionary.txt", "02-UserDictionary.txt"]) - .dir(bugDetectorDirectory) - .fuzzEntryPoint("DictionaryTest") - .sync(true) - .verbose(true) - .build(); - fuzzTest.execute(); - // Check if the contents of the user dictionaries are in the merged dictionary - const userDictionary1 = - readFileSync( - path.join(bugDetectorDirectory, "01-UserDictionary.txt"), - ).toString() + "\n"; - const userDictionary2 = readFileSync( - path.join(bugDetectorDirectory, "01-UserDictionary.txt"), - ).toString(); - const mergedDictionary = readFileSync( - path.join(bugDetectorDirectory, ".JazzerJs-merged-dictionaries"), - ).toString(); - expect(mergedDictionary).toContain(userDictionary1); - expect(mergedDictionary).toContain(userDictionary2); - }); -}); - describe("Prototype Pollution Jest tests", () => { const bugDetectorDirectory = path.join(__dirname, "prototype-pollution"); diff --git a/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt b/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt deleted file mode 100644 index 244d8b2e8..000000000 --- a/tests/bug-detectors/prototype-pollution/01-UserDictionary.txt +++ /dev/null @@ -1,2 +0,0 @@ -# comment -"01234567890-Test" \ No newline at end of file diff --git a/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt b/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt deleted file mode 100644 index 32b7ab0ac..000000000 --- a/tests/bug-detectors/prototype-pollution/02-UserDictionary.txt +++ /dev/null @@ -1,3 +0,0 @@ -# comment -# another comment -"test" \ No newline at end of file From a80b211c0c58c33836d764f813ba4e4d1460e80f Mon Sep 17 00:00:00 2001 From: Norbert Schneider Date: Tue, 18 Jul 2023 10:10:59 +0200 Subject: [PATCH 5/5] Align log message types and prefix --- packages/core/core.ts | 2 +- packages/core/options.ts | 2 +- packages/fuzzer/coverage.ts | 2 +- packages/instrumentor/edgeIdStrategy.ts | 6 ++++-- packages/jest-runner/fuzz.ts | 2 +- packages/jest-runner/worker.ts | 2 +- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/core.ts b/packages/core/core.ts index 561932f4c..c63e925ee 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -268,7 +268,7 @@ function stopFuzzing( if (expectedErrors.length) { const name = errorName(err); if (expectedErrors.includes(name)) { - console.error(`INFO: Received expected error "${name}".`); + console.log(`INFO: Received expected error "${name}".`); stopFuzzing(ERROR_EXPECTED_CODE); } else { printFinding(err); diff --git a/packages/core/options.ts b/packages/core/options.ts index 8312d0eb3..d5daa0c72 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -46,7 +46,7 @@ export function buildFuzzerOption(options: Options) { function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { fuzzerOptions.slice(1).forEach((element) => { if (element.length > 0 && element[0] != "-") { - console.error("INFO: using inputs from:", element); + console.log("INFO: using inputs from:", element); } }); } diff --git a/packages/fuzzer/coverage.ts b/packages/fuzzer/coverage.ts index d375df3e0..da1ec6ab5 100644 --- a/packages/fuzzer/coverage.ts +++ b/packages/fuzzer/coverage.ts @@ -45,7 +45,7 @@ export class CoverageTracker { if (newNumCounters > this.currentNumCounters) { addon.registerNewCounters(this.currentNumCounters, newNumCounters); this.currentNumCounters = newNumCounters; - console.error( + console.log( `INFO: New number of coverage counters ${this.currentNumCounters}`, ); } diff --git a/packages/instrumentor/edgeIdStrategy.ts b/packages/instrumentor/edgeIdStrategy.ts index 797f30efc..f808a287f 100644 --- a/packages/instrumentor/edgeIdStrategy.ts +++ b/packages/instrumentor/edgeIdStrategy.ts @@ -156,7 +156,9 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { break; default: this.releaseLockOnSyncFile(); - console.error(`Multiple entries for ${filename} in ID sync file`); + console.error( + `ERROR: Multiple entries for ${filename} in ID sync file`, + ); process.exit(FileSyncIdStrategy.fatalExitCode); } break; @@ -197,7 +199,7 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { } else { if (this.releaseLockOnSyncFile === undefined) { console.error( - `Lock on ID sync file is not acquired by the first processing instrumenting: ${filename}`, + `ERROR: Lock on ID sync file is not acquired by the first processing instrumenting: ${filename}`, ); process.exit(FileSyncIdStrategy.fatalExitCode); } diff --git a/packages/jest-runner/fuzz.ts b/packages/jest-runner/fuzz.ts index e070b2bc9..1d1c60e98 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -188,7 +188,7 @@ const doneCallbackPromise = ( // there could be quite some time until this one, there is not much we // can do besides printing an error message. console.error( - "Expected done to be called once, but it was called multiple times.", + "ERROR: Expected done to be called once, but it was called multiple times.", ); } doneCalled = true; diff --git a/packages/jest-runner/worker.ts b/packages/jest-runner/worker.ts index 602d97d59..0d565473b 100644 --- a/packages/jest-runner/worker.ts +++ b/packages/jest-runner/worker.ts @@ -314,7 +314,7 @@ export class JazzerWorker { // there could be quite some time until this one, there is not much we // can do besides printing an error message. console.error( - `Expected done to be called once, but it was called multiple times in "${hook.type}" of "${block.name}".`, + `ERROR: Expected done to be called once, but it was called multiple times in "${hook.type}" of "${block.name}".`, ); } doneCalled = true;