[test_all] Rewrite in Python (#4769)

Rewrite test_all in python.
Bash is quite annoying to write and test.
One issue with bash is it is even worse than Python for parallelism (which may be causing #4707).
Rewrite test_all in python and optimize base-runner/Dockerfile for fast development.
Also, combine some docker layers.
This commit is contained in:
jonathanmetzman 2020-12-03 12:52:34 -08:00 committed by GitHub
parent 8169d41e43
commit c8d2319aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 241 additions and 176 deletions

View File

@ -20,12 +20,14 @@ FROM gcr.io/oss-fuzz-base/base-clang AS base-clang
FROM gcr.io/oss-fuzz-base/base-image
# Copy the binaries needed for code coverage and crash symbolization.
COPY --from=base-clang /usr/local/bin/llvm-cov /usr/local/bin/
COPY --from=base-clang /usr/local/bin/llvm-profdata /usr/local/bin/
COPY --from=base-clang /usr/local/bin/llvm-symbolizer /usr/local/bin/
COPY --from=base-clang /usr/local/bin/llvm-cov \
/usr/local/bin/llvm-profdata \
/usr/local/bin/llvm-symbolizer \
/usr/local/bin/
RUN apt-get update
RUN apt-get install -y \
# TODO(metzman): Install libc6-i386 lib32gcc1 instead of libc6-dev-i386 for
# consistency with ClusterFuzz image and to reduce size.
RUN apt-get update && apt-get install -y \
binutils \
file \
fonts-dejavu \
@ -37,24 +39,8 @@ RUN apt-get install -y \
wget \
zip
RUN git clone https://chromium.googlesource.com/chromium/src/tools/code_coverage /opt/code_coverage
RUN pip3 install -r /opt/code_coverage/requirements.txt
COPY bad_build_check \
collect_dft \
coverage \
coverage_helper \
dataflow_tracer.py \
download_corpus \
minijail0 \
reproduce \
run_fuzzer \
run_minijail \
parse_options.py \
targets_list \
test_all \
test_one \
/usr/local/bin/
RUN git clone https://chromium.googlesource.com/chromium/src/tools/code_coverage /opt/code_coverage && \
pip3 install -r /opt/code_coverage/requirements.txt
# Default environment options for various sanitizers.
# Note that these match the settings used in ClusterFuzz and
@ -81,3 +67,20 @@ ENV PATH $PATH:/root/.go/bin:$GOPATH/bin
# Set up Golang coverage modules.
RUN go get github.com/google/oss-fuzz/infra/go/coverage/...
# Do this last to make developing these files easier/faster due to caching.
COPY bad_build_check \
collect_dft \
coverage \
coverage_helper \
dataflow_tracer.py \
download_corpus \
minijail0 \
reproduce \
run_fuzzer \
run_minijail \
parse_options.py \
targets_list \
test_all.py \
test_one \
/usr/local/bin/

View File

@ -11,7 +11,7 @@ docker run -ti gcr.io/oss-fuzz-base/base-runner <command> <args>
|---------|-------------|
| `reproduce <fuzzer_name> <fuzzer_options>` | build all fuzz targets and run specified one with testcase `/testcase` and given options.
| `run_fuzzer <fuzzer_name> <fuzzer_options>` | runs specified fuzzer combining options with `.options` file |
| `test_all` | runs every binary in `/out` as a fuzzer for a while to ensure it works. |
| `test_all.py` | runs every binary in `/out` as a fuzzer for a while to ensure it works. |
# Examples

View File

@ -1,147 +0,0 @@
#!/bin/bash -u
# Copyright 2016 Google Inc.
#
# 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.
#
################################################################################
# Percentage threshold that needs to be reached for marking a build as failed.
ALLOWED_BROKEN_TARGETS_PERCENTAGE=${ALLOWED_BROKEN_TARGETS_PERCENTAGE:-10}
# Test all fuzz targets in the $OUT/ dir.
TOTAL_TARGETS_COUNT=0
# Number of CPUs available, this is needed for running tests in parallel.
NPROC=$(nproc)
# Directories where bad build check results will be written to.
VALID_TARGETS_DIR="/tmp/valid_fuzz_targets"
BROKEN_TARGETS_DIR="/tmp/broken_fuzz_targets"
rm -rf $VALID_TARGETS_DIR
rm -rf $BROKEN_TARGETS_DIR
mkdir $VALID_TARGETS_DIR
mkdir $BROKEN_TARGETS_DIR
# Move the directory the fuzzer is located in to somewhere that doesn't exist
# on the builder to make it more likely that hardcoding /out fails here (since
# it will fail on ClusterFuzz).
TMP_FUZZER_DIR=/tmp/not-out
rm -rf $TMP_FUZZER_DIR
mkdir $TMP_FUZZER_DIR
# Move contents of $OUT/ into $TMP_FUZZER_DIR. We can't move the directory
# itself because it is a mount.
mv $OUT/* $TMP_FUZZER_DIR
INITIAL_OUT=$OUT
export OUT=$TMP_FUZZER_DIR
# Main loop that iterates through all fuzz targets and runs the check.
for FUZZER_BINARY in $(find $TMP_FUZZER_DIR -maxdepth 1 -executable -type f); do
if [ "$FUZZING_LANGUAGE" != "python" ]; then
if file "$FUZZER_BINARY" | grep -v ELF > /dev/null 2>&1; then
continue
fi
fi
# Continue if not a fuzz target.
if [[ $FUZZING_ENGINE != "none" ]]; then
grep "LLVMFuzzerTestOneInput" $FUZZER_BINARY > /dev/null 2>&1 || continue
fi
FUZZER=$(basename $FUZZER_BINARY)
if [[ "$FUZZER" == afl-* ]]; then
continue
fi
echo "INFO: performing bad build checks for $FUZZER_BINARY."
LOG_PATH_FOR_BROKEN_TARGET="${BROKEN_TARGETS_DIR}/${FUZZER}"
# Launch bad build check process in the background. Ignore the exit codes, as
# we check the percentage of broken fuzz targets after running all the checks.
bad_build_check $FUZZER_BINARY &> $LOG_PATH_FOR_BROKEN_TARGET &
# Count total number of fuzz targets being tested.
TOTAL_TARGETS_COUNT=$[$TOTAL_TARGETS_COUNT+1]
# Do not spawn more processes than the number of CPUs available.
n_child_proc=$(jobs -rp | wc -l)
while [ "$n_child_proc" -eq "$NPROC" ]; do
sleep 4
n_child_proc=$(jobs -rp | wc -l)
done
done
# Wait for background processes to finish.
wait
# Restore OUT
export OUT=$INITIAL_OUT
mv $TMP_FUZZER_DIR/* $OUT
# Sanity check in case there are no fuzz targets in the $OUT/ dir.
if [ "$TOTAL_TARGETS_COUNT" -eq "0" ]; then
echo "ERROR: no fuzzers found in $OUT/"
ls -al $OUT
exit 1
fi
# An empty log file indicated that corresponding fuzz target is not broken.
find $BROKEN_TARGETS_DIR -empty -exec mv {} $VALID_TARGETS_DIR \;
# Calculate number of valid and broken fuzz targets.
VALID_TARGETS_COUNT=$(ls $VALID_TARGETS_DIR | wc -l)
BROKEN_TARGETS_COUNT=$(ls $BROKEN_TARGETS_DIR | wc -l)
# Sanity check to make sure that bad build check doesn't skip any fuzz target.
if [ "$TOTAL_TARGETS_COUNT" -ne "$[$VALID_TARGETS_COUNT+$BROKEN_TARGETS_COUNT]" ]; then
echo "ERROR: bad_build_check seems to have a bug, total number of fuzz" \
"does not match number of fuzz targets tested."
echo "Total fuzz targets ($TOTAL_TARGETS_COUNT):"
ls -al $OUT
echo "Valid fuzz targets ($VALID_TARGETS_COUNT):"
ls -al $VALID_TARGETS_DIR
echo "Total fuzz targets ($BROKEN_TARGETS_COUNT):"
ls -al $BROKEN_TARGETS_DIR
exit 1
fi
# Build info about all broken fuzz targets (if any).
if [ "$BROKEN_TARGETS_COUNT" -gt "0" ]; then
echo "Broken fuzz targets ($BROKEN_TARGETS_COUNT):"
for target in $(ls $BROKEN_TARGETS_DIR); do
echo "${target}:"
cat ${BROKEN_TARGETS_DIR}/${target}
done
fi
# Calculate the percentage of broken fuzz targets and make the finel decision.
BROKEN_TARGETS_PERCENTAGE=$[$BROKEN_TARGETS_COUNT*100/$TOTAL_TARGETS_COUNT]
if [ "$BROKEN_TARGETS_PERCENTAGE" -gt "$ALLOWED_BROKEN_TARGETS_PERCENTAGE" ]; then
echo "ERROR: $BROKEN_TARGETS_PERCENTAGE% of fuzz targets seem to be broken." \
"See the list above for a detailed information."
# TODO: figure out how to not fail the "special" cases handled below. Those
# are from "example" and "c-ares" projects and are too small targets to pass.
if [ "$(ls $OUT/do_stuff_fuzzer $OUT/ares_*_fuzzer $OUT/checksum_fuzzer $OUT/fuzz_dump $OUT/fuzz_keyring $OUT/xmltest $OUT/fuzz_compression_sas_rle 2>/dev/null | wc -l)" -gt "0" ]; then
exit 0
fi
exit 1
else
echo "$TOTAL_TARGETS_COUNT fuzzers total, $BROKEN_TARGETS_COUNT seem to be" \
"broken ($BROKEN_TARGETS_PERCENTAGE%)."
exit 0
fi

View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
# 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.
#
################################################################################
"""Does bad_build_check on all fuzz targets in $OUT."""
import contextlib
import multiprocessing
import os
import re
import shutil
import subprocess
import stat
import sys
TMP_FUZZER_DIR = '/tmp/not-out'
EXECUTABLE = stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH
IGNORED_TARGETS = [
r'do_stuff_fuzzer', r'checksum_fuzzer', r'fuzz_dump', r'fuzz_keyring',
r'xmltest', r'fuzz_compression_sas_rle', r'ares_*_fuzzer'
]
IGNORED_TARGETS_RE = re.compile('^' + r'$|^'.join(IGNORED_TARGETS) + '$')
def recreate_directory(directory):
"""Creates |directory|. If it already exists than deletes it first before
creating."""
if os.path.exists(directory):
shutil.rmtree(directory)
os.mkdir(directory)
def move_directory_contents(src_directory, dst_directory):
"""Moves contents of |src_directory| to |dst_directory|."""
src_files = os.listdir(src_directory)
for filename in src_files:
src_path = os.path.join(src_directory, filename)
shutil.move(src_path, dst_directory)
def is_elf(filepath):
"""Returns True if |filepath| is an ELF file."""
result = subprocess.run(['file', filepath],
stdout=subprocess.PIPE,
check=False)
return b'ELF' in result.stdout
def find_fuzz_targets(directory, fuzzing_language):
"""Returns paths to fuzz targets in |directory|."""
# TODO(https://github.com/google/oss-fuzz/issues/4585): Use libClusterFuzz for
# this.
fuzz_targets = []
for filename in os.listdir(directory):
path = os.path.join(directory, filename)
if filename.startswith('afl-'):
continue
if not os.path.isfile(path):
continue
if not os.stat(path).st_mode & EXECUTABLE:
continue
with open(path, 'rb') as file_handle:
binary_contents = file_handle.read()
if b'LLVMFuzzerTestOneInput' not in binary_contents:
continue
if fuzzing_language != 'python' and not is_elf(path):
continue
fuzz_targets.append(path)
return fuzz_targets
def do_bad_build_check(fuzz_target):
"""Runs bad_build_check on |fuzz_target|. Returns a
Subprocess.ProcessResult."""
print('INFO: performing bad build checks for', fuzz_target)
command = ['bad_build_check', fuzz_target]
return subprocess.run(command,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
check=False)
def get_broken_fuzz_targets(bad_build_results, fuzz_targets):
"""Returns a list of broken fuzz targets and their process results in
|fuzz_targets| where each item in |bad_build_results| is the result of
bad_build_check on the corresponding element in |fuzz_targets|."""
broken = []
for result, fuzz_target in zip(bad_build_results, fuzz_targets):
if result.returncode != 0:
broken.append((fuzz_target, result))
return broken
def has_ignored_targets(out_dir):
"""Returns True if |out_dir| has any fuzz targets we are supposed to ignore
bad build checks of."""
out_files = set(os.listdir(out_dir))
for filename in out_files:
if re.match(IGNORED_TARGETS_RE, filename):
return True
return False
@contextlib.contextmanager
def use_different_out_dir():
"""Context manager that moves OUT to TMP_FUZZER_DIR. This is useful for
catching hardcoding. Note that this sets the environment variable OUT and
therefore must be run before multiprocessing.Pool is created. Resets OUT at
the end."""
# Use a fake OUT directory to catch path hardcoding that breaks on
# ClusterFuzz.
out = os.getenv('OUT')
initial_out = out
recreate_directory(TMP_FUZZER_DIR)
out = TMP_FUZZER_DIR
# Set this so that run_fuzzer which is called by bad_build_check works
# properly.
os.environ['OUT'] = out
# We move the contents of the directory because we can't move the
# directory itself because it is a mount.
move_directory_contents(initial_out, out)
try:
yield out
finally:
move_directory_contents(out, initial_out)
shutil.rmtree(out)
os.environ['OUT'] = initial_out
def test_all_outside_out(fuzzing_language, allowed_broken_targets_percentage):
"""Wrapper around test_all that changes OUT and returns the result."""
with use_different_out_dir() as out:
return test_all(out, fuzzing_language, allowed_broken_targets_percentage)
def test_all(out, fuzzing_language, allowed_broken_targets_percentage):
"""Do bad_build_check on all fuzz targets."""
# TODO(metzman): Refactor so that we can convert test_one to python.
fuzz_targets = find_fuzz_targets(out, fuzzing_language)
pool = multiprocessing.Pool()
bad_build_results = pool.map(do_bad_build_check, fuzz_targets)
broken_targets = get_broken_fuzz_targets(bad_build_results, fuzz_targets)
broken_targets_count = len(broken_targets)
if not broken_targets_count:
return True
print('Broken fuzz targets', broken_targets_count)
total_targets_count = len(fuzz_targets)
broken_targets_percentage = 100 * broken_targets_count / total_targets_count
for broken_target, result in broken_targets:
print(broken_target)
# Use write because we can't print binary strings.
sys.stdout.buffer.write(result.stdout + result.stderr + b'\n')
if broken_targets_percentage > allowed_broken_targets_percentage:
print('ERROR: {broken_targets_percentage}% of fuzz targets seem to be '
'broken. See the list above for a detailed information.'.format(
broken_targets_percentage=broken_targets_percentage))
if has_ignored_targets(out):
print('Build check automatically passing because of ignored targets.')
return True
return False
print('{total_targets_count} fuzzers total, {broken_targets_count} '
'seem to be broken ({broken_targets_percentage}%).'.format(
total_targets_count=total_targets_count,
broken_targets_count=broken_targets_count,
broken_targets_percentage=broken_targets_percentage))
return True
def get_allowed_broken_targets_percentage():
"""Returns the value of the environment value
'ALLOWED_BROKEN_TARGETS_PERCENTAGE' as an int or returns a reasonable
default."""
return int(os.getenv('ALLOWED_BROKEN_TARGETS_PERCENTAGE', '10'))
def main():
"""Does bad_build_check on all fuzz targets in parallel. Returns 0 on success.
Returns 1 on failure."""
# Set these environment variables here so that stdout
fuzzing_language = os.getenv('FUZZING_LANGUAGE')
allowed_broken_targets_percentage = get_allowed_broken_targets_percentage()
if not test_all_outside_out(fuzzing_language,
allowed_broken_targets_percentage):
return 1
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -314,7 +314,7 @@ def check_fuzzer_build(out_dir, sanitizer='address'):
command += ['-e', 'OUT=' + out_dir, '--volumes-from', container]
else:
command += ['-v', '%s:/out' % out_dir]
command.extend(['-t', 'gcr.io/oss-fuzz-base/base-runner', 'test_all'])
command.extend(['-t', 'gcr.io/oss-fuzz-base/base-runner', 'test_all.py'])
exit_code = helper.docker_run(command)
if exit_code:
logging.error('Check fuzzer build failed.')

View File

@ -277,7 +277,8 @@ def get_dockerfile_path(project_name):
def _get_corpus_dir(project_name=''):
"""Creates and returns path to /corpus directory for the given project (if specified)."""
"""Creates and returns path to /corpus directory for the given project (if
specified)."""
directory = os.path.join(BUILD_DIR, 'corpus', project_name)
os.makedirs(directory, exist_ok=True)
@ -285,7 +286,8 @@ def _get_corpus_dir(project_name=''):
def _get_output_dir(project_name=''):
"""Creates and returns path to /out directory for the given project (if specified)."""
"""Creates and returns path to /out directory for the given project (if
specified)."""
directory = os.path.join(BUILD_DIR, 'out', project_name)
os.makedirs(directory, exist_ok=True)
@ -293,7 +295,8 @@ def _get_output_dir(project_name=''):
def _get_work_dir(project_name=''):
"""Creates and returns path to /work directory for the given project (if specified)."""
"""Creates and returns path to /work directory for the given project (if
specified)."""
directory = os.path.join(BUILD_DIR, 'work', project_name)
os.makedirs(directory, exist_ok=True)
@ -627,7 +630,7 @@ def check_build(args):
if args.fuzzer_name:
run_args += ['test_one', os.path.join('/out', args.fuzzer_name)]
else:
run_args.append('test_all')
run_args.append('test_all.py')
exit_code = docker_run(run_args)
if exit_code == 0: