From a9f9cda4ccb8c24eef41720b6a96cc1b42eb9d87 Mon Sep 17 00:00:00 2001 From: jonathanmetzman <31354670+jonathanmetzman@users.noreply.github.com> Date: Wed, 30 Nov 2022 15:37:36 -0500 Subject: [PATCH] Fuzz OSS-Fuzz with Atheris and ClusterFuzzLite (#8985) --- .clusterfuzzlite/Dockerfile | 23 +++++++++ .clusterfuzzlite/build.sh | 39 +++++++++++++++ .clusterfuzzlite/coverage_atheris_fuzzer.py | 53 +++++++++++++++++++++ .clusterfuzzlite/project.yaml | 1 + .github/workflows/cflite_pr.yml | 49 +++++++++++++++++++ infra/cifuzz/get_coverage.py | 8 +++- infra/cifuzz/get_coverage_test.py | 14 ++++++ 7 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 .clusterfuzzlite/Dockerfile create mode 100755 .clusterfuzzlite/build.sh create mode 100644 .clusterfuzzlite/coverage_atheris_fuzzer.py create mode 100644 .clusterfuzzlite/project.yaml create mode 100644 .github/workflows/cflite_pr.yml diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 000000000..1e6335efd --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,23 @@ +# Copyright 2022 Google LLC +# +# 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. + +FROM gcr.io/oss-fuzz-base/base-builder-python:v1 +COPY . $SRC/oss-fuzz +WORKDIR oss-fuzz +COPY .clusterfuzzlite/build.sh $SRC/ +RUN cp .clusterfuzzlite/coverage_atheris_fuzzer.py infra +WORKDIR infra/ +RUN pip3 install --upgrade pip +RUN pip3 install -r cifuzz/requirements.txt +RUN cp -r cifuzz/* . diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100755 index 000000000..99fb54c3d --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash -eu +# Copyright 2022 Google LLC +# +# 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. + +fuzzer=coverage_atheris_fuzzer.py +fuzzer_basename=$(basename -s .py $fuzzer) +fuzzer_package=${fuzzer_basename}.pkg + +# To avoid issues with Python version conflicts, or changes in environment +# over time on the OSS-Fuzz bots, we use pyinstaller to create a standalone +# package. Though not necessarily required for reproducing issues, this is +# required to keep fuzzers working properly in OSS-Fuzz. +pyinstaller --distpath $OUT --onefile --name $fuzzer_package $fuzzer + +# Create execution wrapper. Atheris requires that certain libraries are +# preloaded, so this is also done here to ensure compatibility and simplify +# test case reproduction. Since this helper script is what OSS-Fuzz will +# actually execute, it is also always required. +# NOTE: If you are fuzzing python-only code and do not have native C/C++ +# extensions, then remove the LD_PRELOAD line below as preloading sanitizer +# library is not required and can lead to unexpected startup crashes. +echo "#!/bin/sh +# LLVMFuzzerTestOneInput for fuzzer detection. +this_dir=\$(dirname \"\$0\") +LD_PRELOAD=\$this_dir/sanitizer_with_fuzzer.so \ + ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \ + \$this_dir/$fuzzer_package \$@" > $OUT/$fuzzer_basename +chmod +x $OUT/$fuzzer_basename diff --git a/.clusterfuzzlite/coverage_atheris_fuzzer.py b/.clusterfuzzlite/coverage_atheris_fuzzer.py new file mode 100644 index 000000000..d7c916037 --- /dev/null +++ b/.clusterfuzzlite/coverage_atheris_fuzzer.py @@ -0,0 +1,53 @@ +#!/usr/bin/python3 +# Copyright 2022 Google LLC +# +# 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 atheris +import json +import os +from unittest import mock +import sys + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append( + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cifuzz')) + +with atheris.instrument_imports(): + import get_coverage + +REPO_PATH = '/src/curl' +PROJECT_NAME = 'curl' +oss_fuzz_coverage = get_coverage.OSSFuzzCoverage(REPO_PATH, PROJECT_NAME) + + +def TestOneInput(data): + try: + decoded_json = json.loads(data) + except (json.decoder.JSONDecodeError, UnicodeDecodeError): + # Wart + return oss_fuzz_coverage.get_files_covered_by_target('fuzz-target') + + with mock.patch('get_coverage.OSSFuzzCoverage.get_target_coverage', + return_value=decoded_json): + oss_fuzz_coverage.get_files_covered_by_target('fuzz-target') + return 0 + + +def main(): + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml new file mode 100644 index 000000000..d1ad0ae50 --- /dev/null +++ b/.clusterfuzzlite/project.yaml @@ -0,0 +1 @@ +language: python diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml new file mode 100644 index 000000000..a8316f092 --- /dev/null +++ b/.github/workflows/cflite_pr.yml @@ -0,0 +1,49 @@ +name: ClusterFuzzLite PR fuzzing +on: + pull_request: + paths: + - 'infra/**' +permissions: read-all +jobs: + PR: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + sanitizer: + - address + # Override this with the sanitizers you want. + # - undefined + # - memory + steps: + - name: Build Fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/clusterfuzzlite/actions/build_fuzzers@v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + sanitizer: ${{ matrix.sanitizer }} + # Optional but recommended: used to only run fuzzers that are affected + # by the PR. + # See later section on "Git repo for storage". + # storage-repo: https://${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/OWNER/STORAGE-REPO-NAME.git + # storage-repo-branch: main # Optional. Defaults to "main" + # storage-repo-branch-coverage: gh-pages # Optional. Defaults to "gh-pages". + - name: Run Fuzzers (${{ matrix.sanitizer }}) + id: run + uses: google/clusterfuzzlite/actions/run_fuzzers@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 600 + mode: 'code-change' + sanitizer: ${{ matrix.sanitizer }} + # Optional but recommended: used to download the corpus produced by + # batch fuzzing. + # See later section on "Git repo for storage". + # storage-repo: https://${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/OWNER/STORAGE-REPO-NAME.git + # storage-repo-branch: main # Optional. Defaults to "main" + # storage-repo-branch-coverage: gh-pages # Optional. Defaults to "gh-pages". + diff --git a/infra/cifuzz/get_coverage.py b/infra/cifuzz/get_coverage.py index b4b2d25d6..762de7928 100644 --- a/infra/cifuzz/get_coverage.py +++ b/infra/cifuzz/get_coverage.py @@ -49,7 +49,7 @@ class BaseCoverage: """ target_cov = self.get_target_coverage(target) if not target_cov: - logging.info('No coverage available for %s', target) + logging.info('No coverage available for %s.', target) return None coverage_per_file = get_coverage_per_file(target_cov) @@ -192,7 +192,11 @@ def is_file_covered(file_cov): def get_coverage_per_file(target_cov): """Returns the coverage per file within |target_cov|.""" - return target_cov['data'][0]['files'] + try: + return target_cov['data'][0]['files'] + except (IndexError, TypeError, KeyError): + logging.error('target_cov: %s is malformed.', target_cov) + return None def _normalize_repo_path(repo_path): diff --git a/infra/cifuzz/get_coverage_test.py b/infra/cifuzz/get_coverage_test.py index fcfc9bd25..3551f22ad 100644 --- a/infra/cifuzz/get_coverage_test.py +++ b/infra/cifuzz/get_coverage_test.py @@ -17,6 +17,7 @@ import json import unittest from unittest import mock +import parameterized from pyfakefs import fake_filesystem_unittest import pytest @@ -127,6 +128,19 @@ class OSSFuzzCoverageGetFilesCoveredByTargetTest(unittest.TestCase): self.oss_fuzz_coverage = get_coverage.OSSFuzzCoverage( REPO_PATH, PROJECT_NAME) + @parameterized.parameterized.expand([({ + 'data': [] + },), ({ + 'data': [[]] + },), ({ + 'data': [{}] + },)]) + def test_malformed_cov_data(self, coverage_data): + """Tests that covered files can be retrieved from a coverage report.""" + with mock.patch('get_coverage.OSSFuzzCoverage.get_target_coverage', + return_value=coverage_data): + self.oss_fuzz_coverage.get_files_covered_by_target(FUZZ_TARGET) + def test_valid_target(self): """Tests that covered files can be retrieved from a coverage report.""" fuzzer_cov_data = _get_example_curl_coverage()