diff options
author | Leo Neat <leosneat@gmail.com> | 2020-01-29 11:03:43 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-01-29 11:03:43 -0800 |
commit | 8ffc6db00c83e5f75e92b3c4c63c1924597711a1 (patch) | |
tree | 7cca59f92af682000c57be2e0b8ad5e926e8701f | |
parent | 4dc4c0240f96105f2330a0fc1f5f321a6e796ddb (diff) | |
download | oss-fuzz-8ffc6db00c83e5f75e92b3c4c63c1924597711a1.tar.gz |
[Infra] CIFuzz pipeline complete. (#3281)
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Testing action build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working build
* Working fuzzers with out error surface
* Working fuzzers with out error surface
* Working fuzzers with out error surface
* Printing std err
* Adding fuzzer timeout
* Adding fuzzer timeout
* Changing fuzzer timeout to fuzz time
* Formatting and refactoring
* Spelling in fuzz_target.py
* Spelling in fuzz_target.py
* Spelling in fuzz_target.py
* Upload artifact fix
* Upload artifact fix
* Upload artifact fix
* Upload artifact fix
* Upload artifact fix
* Upload artifact fix
* Upload artifact fix
* Refactoring error codes.
* reverting helper.py
* reverting helper.py
* reverting helper.py
* chaning method to static
* moving cifuzz file
* Jonathan changes
* Oliver and Jonathan comments
* Oliver and Jonathan comments
* Oliver and Jonathan comments
* Utils unit tests
* Test formatting and documentation
* Build fuzzer test added
* Changed repo manager errors
* Unit and integration tests complete
* Jonathan comments pt.1
* Jonathan comments pt.1
* Jonathan comments pt.1
* adding cifuzz_test
* Build fuzzer test completed
* Run fuzzers test finished.
* Removed SRC dependency
* Jonathan comments pt.2
* Max comments pt.1
* Max comments pt.2
* removing log specified out stream
* Max comments pt.3
* Adding OSS_FUZZ_HOME env var
* Jonathan comments pt.3
* Formatting
* Olivers comments
* Jonathan comments
-rw-r--r-- | infra/base-images/base-builder/Dockerfile | 2 | ||||
-rw-r--r-- | infra/base-images/base-builder/detect_repo.py | 20 | ||||
-rw-r--r-- | infra/base-images/base-builder/detect_repo_test.py | 4 | ||||
-rw-r--r-- | infra/bisector.py | 7 | ||||
-rw-r--r-- | infra/build_specified_commit.py | 19 | ||||
-rw-r--r-- | infra/build_specified_commit_test.py | 36 | ||||
-rw-r--r-- | infra/cifuzz.py | 88 | ||||
-rw-r--r-- | infra/cifuzz/actions/Dockerfile | 3 | ||||
-rw-r--r-- | infra/cifuzz/actions/action.yml | 5 | ||||
-rw-r--r-- | infra/cifuzz/actions/entrypoint.py | 82 | ||||
-rw-r--r-- | infra/cifuzz/cifuzz.py | 152 | ||||
-rw-r--r-- | infra/cifuzz/cifuzz_test.py | 156 | ||||
-rw-r--r-- | infra/cifuzz/fuzz_target.py | 108 | ||||
-rw-r--r-- | infra/repo_manager.py | 25 | ||||
-rw-r--r-- | infra/repo_manager_test.py | 3 | ||||
-rw-r--r-- | infra/utils.py | 94 | ||||
-rw-r--r-- | infra/utils_test.py | 102 |
17 files changed, 734 insertions, 172 deletions
diff --git a/infra/base-images/base-builder/Dockerfile b/infra/base-images/base-builder/Dockerfile index c34f140c6..0e22bafa2 100644 --- a/infra/base-images/base-builder/Dockerfile +++ b/infra/base-images/base-builder/Dockerfile @@ -104,7 +104,7 @@ RUN mkdir honggfuzz && \ COPY compile compile_afl compile_dataflow compile_libfuzzer compile_honggfuzz \ precompile_honggfuzz srcmap write_labels.py /usr/local/bin/ -COPY detect_repo.py $SRC/ +COPY detect_repo.py /src RUN precompile_honggfuzz diff --git a/infra/base-images/base-builder/detect_repo.py b/infra/base-images/base-builder/detect_repo.py index f57947e7e..e4570ed76 100644 --- a/infra/base-images/base-builder/detect_repo.py +++ b/infra/base-images/base-builder/detect_repo.py @@ -40,11 +40,8 @@ def main(): parser = argparse.ArgumentParser( description= 'Finds a specific git repo in an oss-fuzz project\'s docker file.') - parser.add_argument( - '--src_dir', - help='The location of an oss-fuzz project\'s source directory.', - required=True) parser.add_argument('--repo_name', help='The name of the git repo.') + parser.add_argument('--src_dir', help='The location of the possible repo.') parser.add_argument('--example_commit', help='A commit SHA referencing the project\'s main repo.') @@ -52,18 +49,23 @@ def main(): if not args.repo_name and not args.example_commit: raise ValueError( 'Requires an example commit or a repo name to find repo location.') - for single_dir in os.listdir(args.src_dir): - full_path = os.path.join(args.src_dir, single_dir) + if args.src_dir: + src_dir = args.src_dir + else: + src_dir = os.environ.get('SRC', '/src') + + for single_dir in os.listdir(src_dir): + full_path = os.path.join(src_dir, single_dir) if not os.path.isdir(full_path): continue if args.example_commit and check_for_commit(full_path, args.example_commit): - print('Detected repo:', get_repo(full_path), single_dir) + print('Detected repo:', get_repo(full_path), full_path) return if args.repo_name and check_for_repo_name(full_path, args.repo_name): - print('Detected repo:', get_repo(full_path), single_dir) + print('Detected repo:', get_repo(full_path), full_path) return print('No git repos with specific commit: %s found in %s' % - (args.example_commit, args.src_dir)) + (args.example_commit, src_dir)) def get_repo(repo_path): diff --git a/infra/base-images/base-builder/detect_repo_test.py b/infra/base-images/base-builder/detect_repo_test.py index e9029b7ff..b15bedc6f 100644 --- a/infra/base-images/base-builder/detect_repo_test.py +++ b/infra/base-images/base-builder/detect_repo_test.py @@ -99,8 +99,10 @@ class DetectRepoTest(unittest.TestCase): match = re.search(r'\bDetected repo: ([^ ]+) ([^ ]+)', out.rstrip()) if match and match.group(1) and match.group(2): self.assertEqual(match.group(1), repo_origin) + self.assertEqual(match.group(2), os.path.join(tmp_dir, repo_name)) else: self.assertIsNone(repo_origin) + self.assertIsNone(repo_name) def check_commit_with_repo(self, repo_origin, repo_name, commit, tmp_dir): """Checks the detect repos main method for a specific set of inputs. @@ -121,7 +123,7 @@ class DetectRepoTest(unittest.TestCase): match = re.search(r'\bDetected repo: ([^ ]+) ([^ ]+)', out.rstrip()) if match and match.group(1) and match.group(2): self.assertEqual(match.group(1), repo_origin) - self.assertEqual(match.group(2), repo_name) + self.assertEqual(match.group(2), os.path.join(tmp_dir, repo_name)) else: self.assertIsNone(repo_origin) self.assertIsNone(repo_name) diff --git a/infra/bisector.py b/infra/bisector.py index de9fad71b..11ffd0189 100644 --- a/infra/bisector.py +++ b/infra/bisector.py @@ -31,20 +31,17 @@ This is done with the following steps: """ import argparse -import os import tempfile import build_specified_commit import helper import repo_manager +import utils def main(): """Finds the commit SHA where an error was initally introduced.""" - oss_fuzz_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - if os.getcwd() != oss_fuzz_dir: - print('Changing directory to OSS-Fuzz home directory') - os.chdir(oss_fuzz_dir) + utils.chdir_to_root() parser = argparse.ArgumentParser( description='git bisection for finding introduction of bugs') diff --git a/infra/build_specified_commit.py b/infra/build_specified_commit.py index 3861f0a1a..a4f95eb67 100644 --- a/infra/build_specified_commit.py +++ b/infra/build_specified_commit.py @@ -21,8 +21,10 @@ import os import collections import re import subprocess +import sys import helper +import utils BuildData = collections.namedtuple( 'BuildData', ['project_name', 'engine', 'sanitizer', 'architecture']) @@ -50,7 +52,7 @@ def build_fuzzers_from_commit(commit, build_repo_manager, build_data): '/src', build_repo_manager.repo_name)) -def detect_main_repo(project_name, repo_name=None, commit=None, src_dir='/src'): +def detect_main_repo(project_name, repo_name=None, commit=None): """Checks a docker image for the main repo of an OSS-Fuzz project. Note: The default is to use the repo name to detect the main repo. @@ -62,20 +64,25 @@ def detect_main_repo(project_name, repo_name=None, commit=None, src_dir='/src'): src_dir: The location of the projects source on the docker image. Returns: - The repo's origin, the repo's name. + The repo's origin, the repo's path. """ - # TODO: Add infra for non hardcoded '/src'. + if not repo_name and not commit: print('Error: can not detect main repo without a repo_name or a commit.') return None, None if repo_name and commit: print('Both repo name and commit specific. Using repo name for detection.') - helper.build_image_impl(project_name) + # Change to oss-fuzz main directory so helper.py runs correctly. + utils.chdir_to_root() + if not helper.build_image_impl(project_name): + print('Error: building {} image failed.'.format(project_name), + file=sys.stderr) + return None, None docker_image_name = 'gcr.io/oss-fuzz/' + project_name command_to_run = [ 'docker', 'run', '--rm', '-t', docker_image_name, 'python3', - os.path.join(src_dir, 'detect_repo.py'), '--src_dir', src_dir + os.path.join('/src', 'detect_repo.py') ] if repo_name: command_to_run.extend(['--repo_name', repo_name]) @@ -111,5 +118,5 @@ def execute(command, location=None, check_result=False): raise RuntimeError('Error: %s\n Command: %s\n Return code: %s\n Out: %s' % (err, command, process.returncode, out)) if out is not None: - out = out.decode('ascii') + out = out.decode('ascii').rstrip() return out, process.returncode diff --git a/infra/build_specified_commit_test.py b/infra/build_specified_commit_test.py index 98e749955..14a3577f1 100644 --- a/infra/build_specified_commit_test.py +++ b/infra/build_specified_commit_test.py @@ -66,52 +66,52 @@ class BuildImageIntegrationTests(unittest.TestCase): def test_detect_main_repo_from_commit(self): """Test the detect main repo function from build specific commit module.""" - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'curl', commit='bc5d22c3dede2f04870c37aec9a50474c4b888ad') self.assertEqual(repo_origin, 'https://github.com/curl/curl.git') - self.assertEqual(repo_name, 'curl') + self.assertEqual(repo_path, '/src/curl') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'usrsctp', commit='4886aaa49fb90e479226fcfc3241d74208908232') self.assertEqual(repo_origin, 'https://github.com/weinrank/usrsctp') - self.assertEqual(repo_name, 'usrsctp') + self.assertEqual(repo_path, '/src/usrsctp') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'ndpi', commit='c4d476cc583a2ef1e9814134efa4fbf484564ed7') self.assertEqual(repo_origin, 'https://github.com/ntop/nDPI.git') - self.assertEqual(repo_name, 'ndpi') + self.assertEqual(repo_path, '/src/ndpi') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'notproj', commit='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') self.assertIsNone(repo_origin) - self.assertIsNone(repo_name) + self.assertIsNone(repo_path) def test_detect_main_repo_from_name(self): """Test the detect main repo function from build specific commit module.""" - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'curl', repo_name='curl') self.assertEqual(repo_origin, 'https://github.com/curl/curl.git') - self.assertEqual(repo_name, 'curl') + self.assertEqual(repo_path, '/src/curl') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'yara', repo_name='yara') self.assertEqual(repo_origin, 'https://github.com/VirusTotal/yara.git') - self.assertEqual(repo_name, 'yara') + self.assertEqual(repo_path, '/src/yara') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'usrsctp', repo_name='usrsctp') self.assertEqual(repo_origin, 'https://github.com/weinrank/usrsctp') - self.assertEqual(repo_name, 'usrsctp') + self.assertEqual(repo_path, '/src/usrsctp') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'ndpi', repo_name='nDPI') self.assertEqual(repo_origin, 'https://github.com/ntop/nDPI.git') - self.assertEqual(repo_name, 'ndpi') + self.assertEqual(repo_path, '/src/ndpi') - repo_origin, repo_name = build_specified_commit.detect_main_repo( + repo_origin, repo_path = build_specified_commit.detect_main_repo( 'notproj', repo_name='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') self.assertIsNone(repo_origin) - self.assertIsNone(repo_name) + self.assertIsNone(repo_path) if __name__ == '__main__': diff --git a/infra/cifuzz.py b/infra/cifuzz.py deleted file mode 100644 index 9dcb2b192..000000000 --- a/infra/cifuzz.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2020 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. -"""Module used by CI tools in order to interact with fuzzers. -This module helps CI tools do the following: - 1. Build fuzzers. - 2. Run fuzzers. -Eventually it will be used to help CI tools determine which fuzzers to run. -""" - -import argparse -import os -import tempfile - -import build_specified_commit -import repo_manager -import helper - - -def main(): - """Connects Fuzzers with CI tools. - - Returns: - True on success False on failure. - """ - parser = argparse.ArgumentParser( - description='Help CI tools manage specific fuzzers.') - - subparsers = parser.add_subparsers(dest='command') - build_fuzzer_parser = subparsers.add_parser( - 'build_fuzzers', help='Build an OSS-Fuzz projects fuzzers.') - build_fuzzer_parser.add_argument('project_name') - build_fuzzer_parser.add_argument('repo_name') - build_fuzzer_parser.add_argument('commit_sha') - - run_fuzzer_parser = subparsers.add_parser( - 'run_fuzzers', help='Run an OSS-Fuzz projects fuzzers.') - run_fuzzer_parser.add_argument('project_name') - args = parser.parse_args() - - # Change to oss-fuzz main directory so helper.py runs correctly. - if os.getcwd() != helper.OSSFUZZ_DIR: - os.chdir(helper.OSSFUZZ_DIR) - - if args.command == 'build_fuzzers': - return build_fuzzers(args) == 0 - if args.command == 'run_fuzzer': - print('Not implemented') - return False - print('Invalid argument option, use build_fuzzers or run_fuzzer.') - return False - - -def build_fuzzers(args): - """Builds all of the fuzzers for a specific OSS-Fuzz project. - - Returns: - True on success False on failure. - """ - - # TODO: Fix return value bubble to actually handle errors. - with tempfile.TemporaryDirectory() as tmp_dir: - inferred_url, repo_name = build_specified_commit.detect_main_repo( - args.project_name, repo_name=args.repo_name) - build_repo_manager = repo_manager.RepoManager(inferred_url, - tmp_dir, - repo_name=repo_name) - build_data = build_specified_commit.BuildData( - project_name=args.project_name, - sanitizer='address', - engine='libfuzzer', - architecture='x86_64') - return build_specified_commit.build_fuzzers_from_commit( - args.commit_sha, build_repo_manager, build_data) == 0 - - -if __name__ == '__main__': - main() diff --git a/infra/cifuzz/actions/Dockerfile b/infra/cifuzz/actions/Dockerfile index 7cd442184..fe69d00ce 100644 --- a/infra/cifuzz/actions/Dockerfile +++ b/infra/cifuzz/actions/Dockerfile @@ -34,7 +34,8 @@ RUN add-apt-repository \ RUN apt-get update && apt-get install docker-ce docker-ce-cli containerd.io -y -RUN git clone -b ci-fuzz https://github.com/google/oss-fuzz.git /src/oss-fuzz +ENV OSS_FUZZ_ROOT=/opt/oss-fuzz +RUN git clone https://github.com/google/oss-fuzz.git ${OSS_FUZZ_ROOT} # Copies your code file from action repository to the container COPY entrypoint.py /opt/entrypoint.py diff --git a/infra/cifuzz/actions/action.yml b/infra/cifuzz/actions/action.yml index 11095fbf9..7af4bd493 100644 --- a/infra/cifuzz/actions/action.yml +++ b/infra/cifuzz/actions/action.yml @@ -5,8 +5,13 @@ inputs: project-name: description: 'Name of the corresponding OSS-Fuzz project.' required: true + fuzz-seconds: + description: 'The total time allotted for fuzzing in seconds.' + required: true + default: 360 runs: using: 'docker' image: 'Dockerfile' env: PROJECT_NAME: ${{ inputs.project-name }} + FUZZ_SECONDS: ${{ inputs.fuzz-seconds }} diff --git a/infra/cifuzz/actions/entrypoint.py b/infra/cifuzz/actions/entrypoint.py index 2a0415336..5b07c81fb 100644 --- a/infra/cifuzz/actions/entrypoint.py +++ b/infra/cifuzz/actions/entrypoint.py @@ -12,46 +12,70 @@ # See the License for the specific language governing permissions and # limitations under the License. """Builds and runs specific OSS-Fuzz project's fuzzers for CI tools.""" - +import logging import os -import subprocess import sys +# pylint: disable=wrong-import-position +sys.path.append(os.path.join(os.environ['OSS_FUZZ_ROOT'], 'infra', 'cifuzz')) +import cifuzz + +# TODO: Turn default logging to INFO when CIFuzz is stable +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.DEBUG) + def main(): - """Runs OSS-Fuzz project's fuzzers for CI tools.""" - project_name = os.environ['OSS_FUZZ_PROJECT_NAME'] - repo_name = os.environ['GITHUB_REPOSITORY'].rsplit('/', 1)[-1] - commit_sha = os.environ['GITHUB_SHA'] + """Runs OSS-Fuzz project's fuzzers for CI tools. + This script is used to kick off the Github Actions CI tool. It is the + entrypoint of the Dockerfile in this directory. This action can be added to + any OSS-Fuzz project's workflow that uses Github. + + Required environment variables: + PROJECT_NAME: The name of OSS-Fuzz project. + FUZZ_TIME: The length of time in seconds that fuzzers are to be run. + GITHUB_REPOSITORY: The name of the Github repo that called this script. + GITHUB_SHA: The commit SHA that triggered this script. + + Returns: + 0 on success or 1 on Failure. + """ + oss_fuzz_project_name = os.environ.get('PROJECT_NAME') + fuzz_seconds = int(os.environ.get('FUZZ_SECONDS', 360)) + github_repo_name = os.path.basename(os.environ.get('GITHUB_REPOSITORY')) + commit_sha = os.environ.get('GITHUB_SHA') + + # Get the shared volume directory and create required directorys. + workspace = os.environ.get('GITHUB_WORKSPACE') + if not workspace: + logging.error('This script needs to be run in the Github action context.') + return 1 + git_workspace = os.path.join(workspace, 'storage') + os.makedirs(git_workspace, exist_ok=True) + out_dir = os.path.join(workspace, 'out') + os.makedirs(out_dir, exist_ok=True) # Build the specified project's fuzzers from the current repo state. - print('Building fuzzers\nproject: {0}\nrepo name: {1}\ncommit: {2}'.format( - project_name, repo_name, commit_sha)) - command = [ - 'python3', '/src/oss-fuzz/infra/cifuzz.py', 'build_fuzzers', project_name, - repo_name, commit_sha - ] - print('Running command: "{0}"'.format(' '.join(command))) - try: - subprocess.check_call(command) - except subprocess.CalledProcessError as err: - sys.stderr.write('Error building fuzzers: "{0}"'.format(str(err))) - return err.returncode + if not cifuzz.build_fuzzers(oss_fuzz_project_name, github_repo_name, + commit_sha, git_workspace, out_dir): + logging.error('Error building fuzzers for project %s.', + oss_fuzz_project_name) + return 1 # Run the specified project's fuzzers from the build. - command = [ - 'python3', '/src/oss-fuzz/infra/cifuzz.py', 'run_fuzzers', project_name - ] - print('Running command: "{0}"'.format(' '.join(command))) - try: - subprocess.check_call(command) - except subprocess.CalledProcessError as err: - sys.stderr.write('Error running fuzzers: "{0}"'.format(str(err))) - return err.returncode - print('Fuzzers ran successfully.') + run_status, bug_found = cifuzz.run_fuzzers(oss_fuzz_project_name, + fuzz_seconds, out_dir) + if not run_status: + logging.error('Error occured while running fuzzers for project %s.', + oss_fuzz_project_name) + return 1 + if bug_found: + logging.info('Bug found.') + # Return 2 when a bug was found by a fuzzer causing the CI to fail. + return 2 return 0 if __name__ == '__main__': - sys.exit(main()) diff --git a/infra/cifuzz/cifuzz.py b/infra/cifuzz/cifuzz.py new file mode 100644 index 000000000..81c99c82a --- /dev/null +++ b/infra/cifuzz/cifuzz.py @@ -0,0 +1,152 @@ +# Copyright 2020 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. +"""Module used by CI tools in order to interact with fuzzers. +This module helps CI tools do the following: + 1. Build fuzzers. + 2. Run fuzzers. +Eventually it will be used to help CI tools determine which fuzzers to run. +""" + +import logging +import os +import shutil +import sys + +import fuzz_target + +# pylint: disable=wrong-import-position +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import build_specified_commit +import helper +import repo_manager +import utils + +# TODO: Turn default logging to WARNING when CIFuzz is stable +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.DEBUG) + + +def build_fuzzers(project_name, project_repo_name, commit_sha, git_workspace, + out_dir): + """Builds all of the fuzzers for a specific OSS-Fuzz project. + + Args: + project_name: The name of the OSS-Fuzz project being built. + project_repo_name: The name of the projects repo. + commit_sha: The commit SHA to be checked out and fuzzed. + git_workspace: The location in the shared volume to store git repos. + out_dir: The location in the shared volume to store output artifacts. + + Returns: + True if build succeeded or False on failure. + """ + if not os.path.exists(git_workspace): + logging.error('Invalid git workspace: %s.', format(git_workspace)) + return False + if not os.path.exists(out_dir): + logging.error('Invalid out directory %s.', format(out_dir)) + return False + + inferred_url, oss_fuzz_repo_path = build_specified_commit.detect_main_repo( + project_name, repo_name=project_repo_name) + if not inferred_url or not oss_fuzz_repo_path: + logging.error('Could not detect repo from project %s.', project_name) + return False + src_in_docker = os.path.dirname(oss_fuzz_repo_path) + oss_fuzz_repo_name = os.path.basename(oss_fuzz_repo_path) + + # Checkout projects repo in the shared volume. + build_repo_manager = repo_manager.RepoManager(inferred_url, + git_workspace, + repo_name=oss_fuzz_repo_name) + try: + build_repo_manager.checkout_commit(commit_sha) + except repo_manager.RepoManagerError: + logging.error('Specified commit does not exist.') + # NOTE: Remove return statement for testing. + return False + + command = [ + '--cap-add', 'SYS_PTRACE', '-e', 'FUZZING_ENGINE=libfuzzer', '-e', + 'SANITIZER=address', '-e', 'ARCHITECTURE=x86_64' + ] + container = utils.get_container_name() + if container: + command += ['-e', 'OUT=' + out_dir, '--volumes-from', container] + bash_command = 'rm -rf {0} && cp -r {1} {2} && compile'.format( + os.path.join(src_in_docker, oss_fuzz_repo_name, '*'), + os.path.join(git_workspace, oss_fuzz_repo_name), src_in_docker) + else: + command += [ + '-e', 'OUT=' + '/out', '-v', + '%s:%s' % (os.path.join(git_workspace, oss_fuzz_repo_name), + os.path.join(src_in_docker, oss_fuzz_repo_name)), '-v', + '%s:%s' % (out_dir, '/out') + ] + bash_command = 'compile' + + command.extend([ + 'gcr.io/oss-fuzz/' + project_name, + '/bin/bash', + '-c', + ]) + command.append(bash_command) + + if helper.docker_run(command): + logging.error('Building fuzzers failed.') + return False + return True + + +def run_fuzzers(project_name, fuzz_seconds, out_dir): + """Runs all fuzzers for a specific OSS-Fuzz project. + + Args: + project_name: The name of the OSS-Fuzz project being built. + fuzz_seconds: The total time allotted for fuzzing. + out_dir: The location in the shared volume to store output artifacts. + + Returns: + (True if run was successful, True if bug was found). + """ + if not out_dir or not os.path.exists(out_dir): + logging.error('Unreachable out_dir argument %s.', format(out_dir)) + return False, False + + if not fuzz_seconds or fuzz_seconds < 1: + logging.error('Fuzz_seconds argument must be greater than 1, but was: %s.', + format(fuzz_seconds)) + return False, False + + fuzzer_paths = utils.get_fuzz_targets(out_dir) + if not fuzzer_paths: + logging.error('No fuzzers were found in out directory: %s.', + format(out_dir)) + return False, False + + fuzz_seconds_per_target = fuzz_seconds // len(fuzzer_paths) + + for fuzzer_path in fuzzer_paths: + target = fuzz_target.FuzzTarget(project_name, fuzzer_path, + fuzz_seconds_per_target, out_dir) + test_case, stack_trace = target.fuzz() + if not test_case or not stack_trace: + logging.info('Fuzzer %s, finished running.', target.target_name) + else: + logging.info('Fuzzer %s, detected error: %s.', target.target_name, + stack_trace) + shutil.move(test_case, os.path.join(out_dir, 'testcase')) + return True, True + return True, False diff --git a/infra/cifuzz/cifuzz_test.py b/infra/cifuzz/cifuzz_test.py new file mode 100644 index 000000000..90c93a2da --- /dev/null +++ b/infra/cifuzz/cifuzz_test.py @@ -0,0 +1,156 @@ +# Copyright 2020 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. +"""Test the functionality of the cifuzz module's functions: +1. Building fuzzers. +2. Running fuzzers. +""" + +import os +import sys +import tempfile +import unittest + +# pylint: disable=wrong-import-position +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import cifuzz + +# NOTE: This integration test relies on +# https://github.com/google/oss-fuzz/tree/master/projects/example project +EXAMPLE_PROJECT = 'example' + + +class BuildFuzzersIntegrationTest(unittest.TestCase): + """Test build_fuzzers function in the utils module.""" + + def test_valid(self): + """Test building fuzzers with valid inputs.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(out_path) + os.mkdir(workspace_path) + self.assertTrue( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'oss-fuzz', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + workspace_path, out_path)) + self.assertTrue(os.path.exists(os.path.join(out_path, 'do_stuff_fuzzer'))) + + +def test_invalid_project_name(self): + """Test building fuzzers with invalid project name.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(out_path) + os.mkdir(workspace_path) + self.assertFalse( + cifuzz.build_fuzzers('not_a_valid_project', 'oss-fuzz', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + workspace_path, out_path)) + + +def test_invalid_repo_name(self): + """Test building fuzzers with invalid repo name.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(out_path) + os.mkdir(workspace_path) + self.assertFalse( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'not-real-repo', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + workspace_path, out_path)) + + +def test_invalid_commit_sha(self): + """Test building fuzzers with invalid commit SHA.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(out_path) + os.mkdir(workspace_path) + self.assertFalse( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'oss-fuzz', '', workspace_path, + out_path)) + + +def test_invalid_workspace(self): + """Test building fuzzers with invalid workspace.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + os.mkdir(out_path) + self.assertFalse( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'oss-fuzz', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + 'not/a/dir', out_path)) + + +def test_invalid_out(self): + """Test building fuzzers with invalid out directory.""" + with tempfile.TemporaryDirectory() as tmp_dir: + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(workspace_path) + self.assertFalse( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'oss-fuzz', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + workspace_path, 'not/a/dir')) + + +class RunFuzzersIntegrationTest(unittest.TestCase): + """Test build_fuzzers function in the utils module.""" + + def test_valid(self): + """Test run_fuzzers with a valid build.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + workspace_path = os.path.join(tmp_dir, 'workspace') + os.mkdir(out_path) + os.mkdir(workspace_path) + self.assertTrue( + cifuzz.build_fuzzers(EXAMPLE_PROJECT, 'oss-fuzz', + '0b95fe1039ed7c38fea1f97078316bfc1030c523', + workspace_path, out_path)) + self.assertTrue(os.path.exists(os.path.join(out_path, 'do_stuff_fuzzer'))) + run_success, bug_found = cifuzz.run_fuzzers(EXAMPLE_PROJECT, 5, out_path) + self.assertTrue(run_success) + self.assertTrue(bug_found) + + def test_invlid_build(self): + """Test run_fuzzers with an invalid build.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + os.mkdir(out_path) + run_success, bug_found = cifuzz.run_fuzzers(EXAMPLE_PROJECT, 5, out_path) + self.assertFalse(run_success) + self.assertFalse(bug_found) + + def test_invalid_fuzz_seconds(self): + """Tests run_fuzzers with an invalid fuzz seconds.""" + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, 'out') + os.mkdir(out_path) + run_success, bug_found = cifuzz.run_fuzzers(EXAMPLE_PROJECT, 0, out_path) + self.assertFalse(run_success) + self.assertFalse(bug_found) + + def test_invalid_out_dir(self): + """Tests run_fuzzers with an invalid out directory.""" + run_success, bug_found = cifuzz.run_fuzzers(EXAMPLE_PROJECT, 5, + 'not/a/valid/path') + self.assertFalse(run_success) + self.assertFalse(bug_found) + + +if __name__ == '__main__': + unittest.main() diff --git a/infra/cifuzz/fuzz_target.py b/infra/cifuzz/fuzz_target.py new file mode 100644 index 000000000..9272bd2c4 --- /dev/null +++ b/infra/cifuzz/fuzz_target.py @@ -0,0 +1,108 @@ +# Copyright 2020 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. +"""A module to handle running a fuzz target for a specified amount of time.""" +import logging +import os +import re +import subprocess +import sys + +# pylint: disable=wrong-import-position +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import utils + +# TODO: Turn default logging to WARNING when CIFuzz is stable +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.DEBUG) + + +class FuzzTarget: + """A class to manage a single fuzz target. + + Attributes: + project_name: The name of the OSS-Fuzz project the target is associated. + target_name: The name of the fuzz target. + duration: The length of time in seconds that the target should run. + target_path: The location of the fuzz target binary. + """ + + def __init__(self, project_name, target_path, duration, out_dir): + """Represents a single fuzz target. + + Args: + project_name: The OSS-Fuzz project of this target. + target_path: The location of the fuzz target binary. + duration: The length of time in seconds the target should run. + out_dir: The location of where the output from crashes should be stored. + """ + self.target_name = os.path.basename(target_path) + self.duration = duration + self.project_name = project_name + self.target_path = target_path + self.out_dir = out_dir + + def fuzz(self): + """Starts the fuzz target run for the length of time specified by duration. + + Returns: + (test_case, stack trace) if found or (None, None) on timeout or error. + """ + logging.info('Fuzzer %s, started.', self.target_name) + docker_container = utils.get_container_name() + command = ['docker', 'run', '--rm', '--privileged'] + if docker_container: + command += [ + '--volumes-from', docker_container, '-e', 'OUT=' + self.out_dir + ] + else: + command += ['-v', '%s:%s' % (self.out_dir, '/out')] + + command += [ + '-e', 'FUZZING_ENGINE=libfuzzer', '-e', 'SANITIZER=address', '-e', + 'RUN_FUZZER_MODE=interactive', 'gcr.io/oss-fuzz-base/base-runner', + 'bash', '-c', 'run_fuzzer {0}'.format(self.target_name) + ] + logging.info('Running command: %s', ' '.join(command)) + process = subprocess.Popen(command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + try: + _, err = process.communicate(timeout=self.duration) + except subprocess.TimeoutExpired: + logging.info('Fuzzer %s, finished with timeout.', self.target_name) + return None, None + + logging.info('Fuzzer %s, ended before timeout.', self.target_name) + err_str = err.decode('ascii') + test_case = self.get_test_case(err_str) + if not test_case: + logging.error('No test case found in stack trace.', file=sys.stderr) + return None, None + return test_case, err_str + + def get_test_case(self, error_string): + """Gets the file from a fuzzer run stack trace. + + Args: + error_string: The stack trace string containing the error. + + Returns: + The error test case or None if not found. + """ + match = re.search(r'\bTest unit written to \.\/([^\s]+)', error_string) + if match: + return os.path.join(self.out_dir, match.group(1)) + return None diff --git a/infra/repo_manager.py b/infra/repo_manager.py index b13b91fc5..6c010a478 100644 --- a/infra/repo_manager.py +++ b/infra/repo_manager.py @@ -31,7 +31,7 @@ class RepoManagerError(Exception): """Class to describe the exceptions in RepoManager.""" -class RepoManager(object): +class RepoManager: """Class to manage git repos from python. Attributes: @@ -54,7 +54,7 @@ class RepoManager(object): if repo_name: self.repo_name = repo_name else: - self.repo_name = self.repo_url.split('/')[-1].strip('.git') + self.repo_name = os.path.basename(self.repo_url).strip('.git') self.repo_dir = os.path.join(self.base_dir, self.repo_name) self._clone() @@ -68,8 +68,7 @@ class RepoManager(object): os.makedirs(self.base_dir) self.remove_repo() out, err = build_specified_commit.execute( - ['git', 'clone', self.repo_url], - location=self.base_dir) + ['git', 'clone', self.repo_url, self.repo_name], location=self.base_dir) if not self._is_git_repo(): raise RepoManagerError('%s is not a git repo' % self.repo_url) @@ -98,7 +97,7 @@ class RepoManager(object): # Handle the exception case, if empty string is passed execute will # raise a ValueError if not commit.rstrip(): - raise ValueError('An empty string is not a valid commit SHA') + raise RepoManagerError('An empty string is not a valid commit SHA') _, err_code = build_specified_commit.execute( ['git', 'cat-file', '-e', commit], self.repo_dir) @@ -111,8 +110,8 @@ class RepoManager(object): The current active commit SHA """ out, _ = build_specified_commit.execute(['git', 'rev-parse', 'HEAD'], - self.repo_dir, - check_result=True) + self.repo_dir, + check_result=True) return out.strip('\n') def get_commit_list(self, old_commit, new_commit): @@ -163,14 +162,14 @@ class RepoManager(object): git_path = os.path.join(self.repo_dir, '.git', 'shallow') if os.path.exists(git_path): build_specified_commit.execute(['git', 'fetch', '--unshallow'], - self.repo_dir, - check_result=True) + self.repo_dir, + check_result=True) build_specified_commit.execute(['git', 'checkout', '-f', commit], - self.repo_dir, - check_result=True) + self.repo_dir, + check_result=True) build_specified_commit.execute(['git', 'clean', '-fxd'], - self.repo_dir, - check_result=True) + self.repo_dir, + check_result=True) if self.get_current_commit() != commit: raise RepoManagerError('Error checking out commit %s' % commit) diff --git a/infra/repo_manager_test.py b/infra/repo_manager_test.py index 1c271cf5b..79d1b40c8 100644 --- a/infra/repo_manager_test.py +++ b/infra/repo_manager_test.py @@ -48,7 +48,7 @@ class TestRepoManager(unittest.TestCase): commit_to_test = '036ebac0134de3b72052a46f734e4ca81bb96055' test_repo_manager.checkout_commit(commit_to_test) self.assertEqual(commit_to_test, test_repo_manager.get_current_commit()) - with self.assertRaises(ValueError): + with self.assertRaises(repo_manager.RepoManagerError): test_repo_manager.checkout_commit(' ') with self.assertRaises(repo_manager.RepoManagerError): test_repo_manager.checkout_commit( @@ -75,6 +75,7 @@ class TestRepoManager(unittest.TestCase): test_repo_manager.get_commit_list(new_commit, 'asdfasdf') with self.assertRaises(repo_manager.RepoManagerError): # Testing commits out of order + # pylint: disable=arguments-out-of-order test_repo_manager.get_commit_list(new_commit, old_commit) diff --git a/infra/utils.py b/infra/utils.py new file mode 100644 index 000000000..c2820c38b --- /dev/null +++ b/infra/utils.py @@ -0,0 +1,94 @@ +# Copyright 2020 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. +"""Utilities for OSS-Fuzz infrastructure.""" + +import os +import re +import stat + +import helper + +ALLOWED_FUZZ_TARGET_EXTENSIONS = ['', '.exe'] +FUZZ_TARGET_SEARCH_STRING = 'LLVMFuzzerTestOneInput' +VALID_TARGET_NAME = re.compile(r'^[a-zA-Z0-9_-]+$') + + +def chdir_to_root(): + """Changes cwd to OSS-Fuzz root directory.""" + # Change to oss-fuzz main directory so helper.py runs correctly. + if os.getcwd() != helper.OSSFUZZ_DIR: + os.chdir(helper.OSSFUZZ_DIR) + + +def is_fuzz_target_local(file_path): + """Returns whether |file_path| is a fuzz target binary (local path). + Copied from clusterfuzz src/python/bot/fuzzers/utils.py + with slight modifications. + """ + filename, file_extension = os.path.splitext(os.path.basename(file_path)) + if not VALID_TARGET_NAME.match(filename): + # Check fuzz target has a valid name (without any special chars). + return False + + if file_extension not in ALLOWED_FUZZ_TARGET_EXTENSIONS: + # Ignore files with disallowed extensions (to prevent opening e.g. .zips). + return False + + if not os.path.exists(file_path) or not os.access(file_path, os.X_OK): + return False + + if filename.endswith('_fuzzer'): + return True + + if os.path.exists(file_path) and not stat.S_ISREG(os.stat(file_path).st_mode): + return False + + with open(file_path, 'rb') as file_handle: + return file_handle.read().find(FUZZ_TARGET_SEARCH_STRING.encode()) != -1 + + +def get_fuzz_targets(path): + """Get list of fuzz targets in a directory. + + Args: + path: A path to search for fuzz targets in. + + Returns: + A list of paths to fuzzers or an empty list if None. + """ + if not os.path.exists(path): + return [] + fuzz_target_paths = [] + for root, _, _ in os.walk(path): + for filename in os.listdir(path): + file_path = os.path.join(root, filename) + if is_fuzz_target_local(file_path): + fuzz_target_paths.append(file_path) + + return fuzz_target_paths + + +def get_container_name(): + """Gets the name of the current docker container you are in. + /proc/self/cgroup can be used to check control groups e.g. Docker. + See: https://docs.docker.com/config/containers/runmetrics/ for more info. + + Returns: + Container name or None if not in a container. + """ + with open('/proc/self/cgroup') as file_handle: + if 'docker' not in file_handle.read(): + return None + with open('/etc/hostname') as file_handle: + return file_handle.read().strip() diff --git a/infra/utils_test.py b/infra/utils_test.py new file mode 100644 index 000000000..ab3d216fb --- /dev/null +++ b/infra/utils_test.py @@ -0,0 +1,102 @@ +# Copyright 2020 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. +"""Test the functionality of the utils module's functions: +1. is_fuzz_target_local +2. get_fuzz_targets +3. get_env_var +""" + +import os +import unittest + +import utils +import helper + +EXAMPLE_PROJECT = 'example' + + +class IsFuzzTargetLocalUnitTest(unittest.TestCase): + """Test is_fuzz_target_local function in the utils module.""" + + def test_invalid_filepath(self): + """Test the function with an invalid file path.""" + is_local = utils.is_fuzz_target_local('not/a/real/file') + self.assertFalse(is_local) + is_local = utils.is_fuzz_target_local('') + self.assertFalse(is_local) + is_local = utils.is_fuzz_target_local(' ') + self.assertFalse(is_local) + + def test_valid_filepath(self): + """Checks is_fuzz_target_local function with a valid filepath.""" + utils.chdir_to_root() + helper.build_fuzzers_impl(EXAMPLE_PROJECT, + True, + 'libfuzzer', + 'address', + 'x86_64', [], + None, + no_cache=False, + mount_location=None) + is_local = utils.is_fuzz_target_local( + os.path.join(helper.OSSFUZZ_DIR, 'build', 'out', EXAMPLE_PROJECT, + 'do_stuff_fuzzer')) + self.assertTrue(is_local) + is_local = utils.is_fuzz_target_local( + os.path.join(helper.OSSFUZZ_DIR, 'build', 'out', EXAMPLE_PROJECT, + 'do_stuff_fuzzer.dict')) + self.assertFalse(is_local) + + +class GetFuzzTargetsUnitTest(unittest.TestCase): + """Test get_fuzz_targets function in the utils module.""" + + def test_valid_filepath(self): + """Tests that fuzz targets can be retrieved once the fuzzers are built.""" + utils.chdir_to_root() + helper.build_fuzzers_impl(EXAMPLE_PROJECT, + True, + 'libfuzzer', + 'address', + 'x86_64', [], + None, + no_cache=False, + mount_location=None) + fuzz_targets = utils.get_fuzz_targets( + os.path.join(helper.OSSFUZZ_DIR, 'build', 'out', EXAMPLE_PROJECT)) + self.assertCountEqual(fuzz_targets, [ + os.path.join(helper.OSSFUZZ_DIR, 'build', 'out', EXAMPLE_PROJECT, + 'do_stuff_fuzzer') + ]) + fuzz_targets = utils.get_fuzz_targets( + os.path.join(helper.OSSFUZZ_DIR, 'infra')) + self.assertFalse(fuzz_targets) + + def test_invalid_filepath(self): + """Tests what get_fuzz_targets return when invalid filepath is used.""" + utils.chdir_to_root() + helper.build_fuzzers_impl(EXAMPLE_PROJECT, + True, + 'libfuzzer', + 'address', + 'x86_64', [], + None, + no_cache=False, + mount_location=None) + fuzz_targets = utils.get_fuzz_targets('not/a/valid/file/path') + self.assertFalse(fuzz_targets) + + +if __name__ == '__main__': + unittest.main() |