diff --git a/.gitignore b/.gitignore index db6659d5..fec9b17a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,3 @@ 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/ \ No newline at end of file diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 69f757ed..6ee70f85 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 3fae17b0..8893acc6 100644 --- a/examples/bug-detectors/command-injection/custom-hooks.js +++ b/examples/bug-detectors/command-injection/custom-hooks.js @@ -17,11 +17,12 @@ */ const { registerReplaceHook } = require("@jazzer.js/hooking"); -const { reportFinding } = require("@jazzer.js/bug-detectors"); -const { guideTowardsEquality } = require("@jazzer.js/fuzzer"); +const { guideTowardsEquality, reportFinding } = require("@jazzer.js/core"); /** - * 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", diff --git a/examples/bug-detectors/command-injection/package.json b/examples/bug-detectors/command-injection/package.json index 3a4af53d..edb79180 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 00000000..c9d439e8 --- /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/.gitignore b/examples/bug-detectors/path-traversal/.gitignore new file mode 100644 index 00000000..56f3321d --- /dev/null +++ b/examples/bug-detectors/path-traversal/.gitignore @@ -0,0 +1,2 @@ +!corpus/test.zip +corpus/* diff --git a/examples/bug-detectors/path-traversal/package.json b/examples/bug-detectors/path-traversal/package.json index 1fcd94d5..dd37d595 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 00000000..dae63b2c --- /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/packages/core/jazzer.ts b/examples/bug-detectors/prototype-pollution/fuzz.js similarity index 56% rename from packages/core/jazzer.ts rename to examples/bug-detectors/prototype-pollution/fuzz.js index a51d3842..7828ea77 100644 --- a/packages/core/jazzer.ts +++ b/examples/bug-detectors/prototype-pollution/fuzz.js @@ -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. @@ -15,20 +14,13 @@ * limitations under the License. */ -import { - exploreState, - guideTowardsContainment, - guideTowardsEquality, -} from "@jazzer.js/fuzzer"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const protobuf = require("protobufjs"); -export interface Jazzer { - guideTowardsEquality: typeof guideTowardsEquality; - guideTowardsContainment: typeof guideTowardsContainment; - exploreState: typeof exploreState; -} - -export const jazzer: Jazzer = { - guideTowardsEquality, - guideTowardsContainment, - exploreState, +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 00000000..ca069a7d --- /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 00000000..e1de5a1b --- /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/package-lock.json b/package-lock.json index f22e503d..57c7df6d 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 new file mode 100644 index 00000000..05946627 --- /dev/null +++ b/packages/bug-detectors/DEVELOPMENT.md @@ -0,0 +1,162 @@ +# 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 guiding functions from the `@jazzer.js/core` package: + +```typescript +import { + guideTowardsEquality, + guideTowardsContainment, + exploreState, +} from "@jazzer.js/core"; +``` + +There are several ways to guide the fuzzing process: + +- ```typescript + guideTowardsEquality(current: string, target: string, id: number) + ``` + + Instructs the fuzzer to guide its mutations towards making `current` equal to + `target`. + +- ```typescript + guideTowardsContainment(needle: string, haystack: string, id: number) + ``` + + Instructs the fuzzer to guide its mutations towards making `haystack` contain + `needle` as a substring. + +- ```typescript + 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/core`: + +```typescript +import { reportFinding } from "@jazzer.js/core"; +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 00000000..8e766a5c --- /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 00000000..9a55262d --- /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/findings.ts b/packages/bug-detectors/findings.ts deleted file mode 100644 index d55c6c8d..00000000 --- a/packages/bug-detectors/findings.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2022 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 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.). -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. - * - * @param findingMessage - The finding to be saved and thrown. - */ -export function reportFinding(findingMessage: string): void { - // After saving the first finding, ignore all subsequent errors. - if (firstFinding) { - return; - } - firstFinding = new Finding(findingMessage); - throw firstFinding; -} diff --git a/packages/bug-detectors/index.ts b/packages/bug-detectors/index.ts index 4552bbe5..9804a60b 100644 --- a/packages/bug-detectors/index.ts +++ b/packages/bug-detectors/index.ts @@ -14,41 +14,4 @@ * limitations under the License. */ -// Export user-facing API for writing custom bug detectors. -export { - reportFinding, - getFirstFinding, - clearFirstFinding, - 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; -} - -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 { getBugDetectorConfiguration } from "./configuration"; diff --git a/packages/bug-detectors/internal/command-injection.ts b/packages/bug-detectors/internal/command-injection.ts index ca609f93..aab5e61f 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 { guideTowardsContainment } from "@jazzer.js/fuzzer"; +import { guideTowardsContainment, reportFinding } from "@jazzer.js/core"; import { registerBeforeHook } from "@jazzer.js/hooking"; /** diff --git a/packages/bug-detectors/internal/path-traversal.ts b/packages/bug-detectors/internal/path-traversal.ts index aa5af367..b7dbe8ce 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 { guideTowardsContainment } from "@jazzer.js/fuzzer"; +import { reportFinding, guideTowardsContainment } from "@jazzer.js/core"; import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking"; /** diff --git a/packages/bug-detectors/internal/prototype-pollution.ts b/packages/bug-detectors/internal/prototype-pollution.ts new file mode 100644 index 00000000..cad73eed --- /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, + registerAfterEachCallback, + addDictionary, + registerInstrumentationPlugin, + instrumentationGuard, +} from "@jazzer.js/core"; + +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/bug-detectors/package.json b/packages/bug-detectors/package.json index aab872fb..cdb8c6a0 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 643b3118..e6f45908 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/api.ts b/packages/core/api.ts new file mode 100644 index 00000000..e4dd8a45 --- /dev/null +++ b/packages/core/api.ts @@ -0,0 +1,42 @@ +/* + * 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 { 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; +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/callback.ts b/packages/core/callback.ts new file mode 100644 index 00000000..d91ab779 --- /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/cli.ts b/packages/core/cli.ts index a15f49a6..f69aea0d 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") @@ -206,6 +207,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 c3bc62f1..c63e925e 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -23,19 +23,16 @@ import * as reports from "istanbul-reports"; import * as fuzzer from "@jazzer.js/fuzzer"; import * as hooking from "@jazzer.js/hooking"; -import { - clearFirstFinding, - Finding, - getFirstFinding, - loadBugDetectors, -} from "@jazzer.js/bug-detectors"; +import { clearFirstFinding, getFirstFinding, printFinding } from "./finding"; import { FileSyncIdStrategy, Instrumentor, MemorySyncIdStrategy, registerInstrumentor, } from "@jazzer.js/instrumentor"; -import { builtinModules } from "module"; +import { callbacks } from "./callback"; +import { ensureFilepath, importModule } from "./utils"; +import { buildFuzzerOption } from "./options"; // Remove temporary files on exit tmp.setGracefulCleanup(); @@ -63,10 +60,8 @@ export interface Options { coverageDirectory: string; coverageReporters: reports.ReportType[]; disableBugDetectors: string[]; -} - -interface FuzzModule { - [fuzzEntryPoint: string]: fuzzer.FuzzTarget; + mode?: "fuzzing" | "regression"; + verbose?: boolean; } /* eslint no-var: 0 */ @@ -77,8 +72,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,43 +87,77 @@ 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, ); - // Built-in functions cannot be hooked by the instrumentor, so we manually hook them here. - await hookBuiltInFunctions(hooking.hookManager); -} - -// 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, - ); - } - } - } + 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)); + + await hooking.hookManager.finalizeHooks(); } -export function registerGlobals() { +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[], +): 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)) + ); } export async function startFuzzing(options: Options) { @@ -156,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, @@ -183,8 +205,7 @@ export async function startFuzzingNoInit( ); }; - const fuzzerOptions = buildFuzzerOptions(options); - logInfoAboutFuzzerOptions(fuzzerOptions); + const fuzzerOptions = buildFuzzerOption(options); if (options.sync) { return Promise.resolve().then(() => @@ -204,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[], @@ -300,10 +268,10 @@ 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 { - printError(err); + printFinding(err); console.error( `ERROR: Received error "${name}" is not in expected errors [${expectedErrors}].`, ); @@ -314,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(); } @@ -332,87 +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.dryRun) { - // 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"); - - return [prepareLibFuzzerArg0(opts), ...opts]; -} - async function loadFuzzFunction(options: Options): Promise { const fuzzTarget = await importModule(options.fuzzTarget); if (!fuzzTarget) { @@ -436,11 +323,24 @@ 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; let result: void | Promise = undefined; try { + 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 @@ -448,6 +348,7 @@ export function wrapFuzzFunctionForBugDetection( if (result instanceof Promise) { result = result.then( (result) => { + callbacks.runAfterEachCallbacks(); return throwIfError() ?? result; }, (reason) => { @@ -458,6 +359,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)) { + callbacks.runAfterEachCallbacks(); + } return throwIfError(fuzzTargetError) ?? result; }; } else { @@ -465,54 +370,27 @@ export function wrapFuzzFunctionForBugDetection( data: Buffer, done: (err?: Error) => void, ): void | Promise => { + let result: void | Promise = undefined; try { + callbacks.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(); } + callbacks.runAfterEachCallbacks(); done(finding ?? err); }); } catch (e) { + callbacks.runAfterEachCallbacks(); throwIfError(e); } + return result; }; } } -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 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/core/dictionary.test.ts b/packages/core/dictionary.test.ts new file mode 100644 index 00000000..509e8c42 --- /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 new file mode 100644 index 00000000..0388e703 --- /dev/null +++ b/packages/core/dictionary.ts @@ -0,0 +1,74 @@ +/* + * 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 tmp from "tmp"; + +/** + * 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); + } +} + +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 new file mode 100644 index 00000000..2dd51103 --- /dev/null +++ b/packages/core/finding.ts @@ -0,0 +1,110 @@ +/* + * 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 process from "process"; + +export class Finding extends Error {} + +// 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; +} + +export function clearFirstFinding(): void { + firstFinding = undefined; +} + +/** + * 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 | never { + // After saving the first finding, ignore all subsequent errors. + if (firstFinding) { + return; + } + 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 00000000..d5daa0c7 --- /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.log("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/package.json b/packages/core/package.json index 2b02fa4e..923b02ef 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 cb739c09..bd4e839e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -10,9 +10,6 @@ }, { "path": "../hooking" - }, - { - "path": "../bug-detectors" } ] } 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 a493bd4f..23a03a49 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 00000000..d65cd6e5 --- /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/fuzzer/coverage.ts b/packages/fuzzer/coverage.ts index d375df3e..da1ec6ab 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/fuzzer/fuzzer.ts b/packages/fuzzer/fuzzer.ts index bde7804d..0cda36f5 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 f5146727..ff195c4e 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,79 @@ 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 + */ +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); +} + +/** + * 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 + */ +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); +} + +/** + * 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) { + // 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 f531f1cc..b7b82b9d 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 ebd12c12..f2093d23 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 06398b4f..7a9aaa3a 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. @@ -14,6 +14,7 @@ * limitations under the License. */ +import { builtinModules } from "module"; import { AfterHookFn, BeforeHookFn, @@ -21,33 +22,66 @@ import { HookFn, HookType, ReplaceHookFn, - logHooks, - hookTracker, } from "./hook"; +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}`, ); } @@ -57,17 +91,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.", @@ -75,33 +109,40 @@ 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[] = []; + /** + * 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, @@ -148,10 +189,6 @@ export class HookManager { ); } - getMatchingHooks(filepath: string): Hook[] { - return this._hooks.filter((hook) => filepath.includes(hook.pkg)); - } - callHook( id: number, thisPtr: object, @@ -182,24 +219,8 @@ export class HookManager { } } -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; -} - export const hookManager = new HookManager(); -// convenience functions to register hooks + export function registerBeforeHook( target: string, pkg: string, @@ -255,3 +276,23 @@ export async function hookBuiltInFunction(hook: Hook): Promise { logHooks([hook]); hookTracker.addApplied(hook.pkg, hook.target); } + +/** + * 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; + } + 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; +} diff --git a/packages/hooking/tracker.ts b/packages/hooking/tracker.ts new file mode 100644 index 00000000..2b65617f --- /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/edgeIdStrategy.ts b/packages/instrumentor/edgeIdStrategy.ts index 797f30ef..f808a287 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/instrumentor/guard.ts b/packages/instrumentor/guard.ts new file mode 100644 index 00000000..ee4270b0 --- /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 c43ead52..60211e80 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, @@ -73,7 +76,11 @@ export class Instrumentor { const shouldInstrumentFile = this.shouldInstrumentForFuzzing(filename); if (shouldInstrumentFile) { - transformations.push(codeCoverage(this.idStrategy), compareHooks); + transformations.push( + ...instrumentationPlugins.plugins, + codeCoverage(this.idStrategy), + compareHooks, + ); } if (hookManager.hasFunctionsToHook(filename)) { diff --git a/packages/instrumentor/plugin.ts b/packages/instrumentor/plugin.ts new file mode 100644 index 00000000..4ce9e90c --- /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 68394ec1..e5ab3e80 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/packages/jest-runner/config.ts b/packages/jest-runner/config.ts index 00743b9c..7c3407f1 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 ad07e137..1d1c60e9 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}`); } }; @@ -186,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/index.ts b/packages/jest-runner/index.ts index 1e5f8d85..524578f2 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/packages/jest-runner/worker.ts b/packages/jest-runner/worker.ts index 602d97d5..0d565473 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; diff --git a/tests/bug-detectors/.gitignore b/tests/bug-detectors/.gitignore new file mode 100644 index 00000000..6546ae9a --- /dev/null +++ b/tests/bug-detectors/.gitignore @@ -0,0 +1,2 @@ +.jazzerjsrc.json +FRIENDLY diff --git a/tests/bug-detectors/general/package.json b/tests/bug-detectors/general/package.json index 17e7652a..229ce0ef 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 164ad4a7..1caf5061 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 00000000..96a06d1f --- /dev/null +++ b/tests/bug-detectors/prototype-pollution.test.js @@ -0,0 +1,402 @@ +/* + * 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 { + FuzzTestBuilder, + FuzzingExitCode, + JestRegressionExitCode, +} = require("../helpers.js"); + +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 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/fuzz.js b/tests/bug-detectors/prototype-pollution/fuzz.js new file mode 100644 index 00000000..35cf4ffa --- /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 00000000..2dc32b77 --- /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 00000000..b9c7e3f3 --- /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 00000000..ceb46e3f --- /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 00000000..9719be17 --- /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/fuzztests/core.fuzz.js b/tests/bug-detectors/prototype-pollution/tests.fuzz.js similarity index 56% rename from fuzztests/core.fuzz.js rename to tests/bug-detectors/prototype-pollution/tests.fuzz.js index 511e7320..bbf1b2a4 100644 --- a/fuzztests/core.fuzz.js +++ b/tests/bug-detectors/prototype-pollution/tests.fuzz.js @@ -14,21 +14,24 @@ * limitations under the License. */ -const { ensureFilepath } = require("@jazzer.js/core"); +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__); + }); -const cwd = process.cwd(); + it.fuzz("Variable declarations", (data) => { + const a = { __proto__: { a: 10 } }; + }); -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"); - } - } + 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b diff --git a/tests/helpers.js b/tests/helpers.js index 10283e5f..4959e17b 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, ); }