1# Copyright 2020 Google Inc. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14# 15################################################################################ 16#!/usr/bin/env python3 17"""Starts project build on Google Cloud Builder. 18 19Usage: build_project.py <project_dir> 20""" 21 22from __future__ import print_function 23 24import argparse 25import collections 26import datetime 27import json 28import logging 29import os 30import posixpath 31import re 32import sys 33 34from googleapiclient.discovery import build as cloud_build 35import oauth2client.client 36import six 37import yaml 38 39import build_lib 40 41FUZZING_BUILD_TYPE = 'fuzzing' 42 43GCB_LOGS_BUCKET = 'oss-fuzz-gcb-logs' 44 45DEFAULT_ARCHITECTURES = ['x86_64'] 46DEFAULT_ENGINES = ['libfuzzer', 'afl', 'honggfuzz'] 47DEFAULT_SANITIZERS = ['address', 'undefined'] 48 49LATEST_VERSION_FILENAME = 'latest.version' 50LATEST_VERSION_CONTENT_TYPE = 'text/plain' 51 52QUEUE_TTL_SECONDS = 60 * 60 * 24 # 24 hours. 53 54PROJECTS_DIR = os.path.abspath( 55 os.path.join(__file__, os.path.pardir, os.path.pardir, os.path.pardir, 56 os.path.pardir, 'projects')) 57 58DEFAULT_GCB_OPTIONS = {'machineType': 'N1_HIGHCPU_32'} 59 60Config = collections.namedtuple( 61 'Config', ['testing', 'test_image_suffix', 'branch', 'parallel']) 62 63WORKDIR_REGEX = re.compile(r'\s*WORKDIR\s*([^\s]+)') 64 65 66class Build: # pylint: disable=too-few-public-methods 67 """Class representing the configuration for a build.""" 68 69 def __init__(self, fuzzing_engine, sanitizer, architecture): 70 self.fuzzing_engine = fuzzing_engine 71 self.sanitizer = sanitizer 72 self.architecture = architecture 73 self.targets_list_filename = build_lib.get_targets_list_filename( 74 self.sanitizer) 75 76 @property 77 def out(self): 78 """Returns the out directory for the build.""" 79 return posixpath.join( 80 '/workspace/out/', 81 f'{self.fuzzing_engine}-{self.sanitizer}-{self.architecture}') 82 83 84def get_project_data(project_name): 85 """Returns a tuple containing the contents of the project.yaml and Dockerfile 86 of |project_name|. Raises a FileNotFoundError if there is no Dockerfile for 87 |project_name|.""" 88 project_dir = os.path.join(PROJECTS_DIR, project_name) 89 dockerfile_path = os.path.join(project_dir, 'Dockerfile') 90 try: 91 with open(dockerfile_path) as dockerfile: 92 dockerfile = dockerfile.read() 93 except FileNotFoundError: 94 logging.error('Project "%s" does not have a dockerfile.', project_name) 95 raise 96 project_yaml_path = os.path.join(project_dir, 'project.yaml') 97 with open(project_yaml_path, 'r') as project_yaml_file_handle: 98 project_yaml_contents = project_yaml_file_handle.read() 99 return project_yaml_contents, dockerfile 100 101 102class Project: # pylint: disable=too-many-instance-attributes 103 """Class representing an OSS-Fuzz project.""" 104 105 def __init__(self, name, project_yaml_contents, dockerfile, image_project): 106 project_yaml = yaml.safe_load(project_yaml_contents) 107 self.name = name 108 self.image_project = image_project 109 self.workdir = workdir_from_dockerfile(dockerfile) 110 set_yaml_defaults(project_yaml) 111 self._sanitizers = project_yaml['sanitizers'] 112 self.disabled = project_yaml['disabled'] 113 self.architectures = project_yaml['architectures'] 114 self.fuzzing_engines = project_yaml['fuzzing_engines'] 115 self.coverage_extra_args = project_yaml['coverage_extra_args'] 116 self.labels = project_yaml['labels'] 117 self.fuzzing_language = project_yaml['language'] 118 self.run_tests = project_yaml['run_tests'] 119 120 @property 121 def sanitizers(self): 122 """Returns processed sanitizers.""" 123 assert isinstance(self._sanitizers, list) 124 processed_sanitizers = [] 125 for sanitizer in self._sanitizers: 126 if isinstance(sanitizer, six.string_types): 127 processed_sanitizers.append(sanitizer) 128 elif isinstance(sanitizer, dict): 129 for key in sanitizer.keys(): 130 processed_sanitizers.append(key) 131 132 return processed_sanitizers 133 134 @property 135 def image(self): 136 """Returns the docker image for the project.""" 137 return f'gcr.io/{self.image_project}/{self.name}' 138 139 140def get_last_step_id(steps): 141 """Returns the id of the last step in |steps|.""" 142 return steps[-1]['id'] 143 144 145def set_yaml_defaults(project_yaml): 146 """Sets project.yaml's default parameters.""" 147 project_yaml.setdefault('disabled', False) 148 project_yaml.setdefault('architectures', DEFAULT_ARCHITECTURES) 149 project_yaml.setdefault('sanitizers', DEFAULT_SANITIZERS) 150 project_yaml.setdefault('fuzzing_engines', DEFAULT_ENGINES) 151 project_yaml.setdefault('run_tests', True) 152 project_yaml.setdefault('coverage_extra_args', '') 153 project_yaml.setdefault('labels', {}) 154 155 156def is_supported_configuration(build): 157 """Check if the given configuration is supported.""" 158 fuzzing_engine_info = build_lib.ENGINE_INFO[build.fuzzing_engine] 159 if build.architecture == 'i386' and build.sanitizer != 'address': 160 return False 161 return (build.sanitizer in fuzzing_engine_info.supported_sanitizers and 162 build.architecture in fuzzing_engine_info.supported_architectures) 163 164 165def workdir_from_dockerfile(dockerfile): 166 """Parses WORKDIR from the Dockerfile.""" 167 dockerfile_lines = dockerfile.split('\n') 168 for line in dockerfile_lines: 169 match = re.match(WORKDIR_REGEX, line) 170 if match: 171 # We need to escape '$' since they're used for subsitutions in Container 172 # Builer builds. 173 return match.group(1).replace('$', '$$') 174 175 return '/src' 176 177 178def get_datetime_now(): 179 """Returns datetime.datetime.now(). Used for mocking.""" 180 return datetime.datetime.now() 181 182 183def get_env(fuzzing_language, build): 184 """Returns an environment for building. The environment is returned as a list 185 and is suitable for use as the "env" parameter in a GCB build step. The 186 environment variables are based on the values of |fuzzing_language| and 187 |build.""" 188 env_dict = { 189 'FUZZING_LANGUAGE': fuzzing_language, 190 'FUZZING_ENGINE': build.fuzzing_engine, 191 'SANITIZER': build.sanitizer, 192 'ARCHITECTURE': build.architecture, 193 # Set HOME so that it doesn't point to a persisted volume (see 194 # https://github.com/google/oss-fuzz/issues/6035). 195 'HOME': '/root', 196 'OUT': build.out, 197 } 198 return list(sorted([f'{key}={value}' for key, value in env_dict.items()])) 199 200 201def get_compile_step(project, build, env, parallel): 202 """Returns the GCB step for compiling |projects| fuzzers using |env|. The type 203 of build is specified by |build|.""" 204 failure_msg = ( 205 '*' * 80 + '\nFailed to build.\nTo reproduce, run:\n' 206 f'python infra/helper.py build_image {project.name}\n' 207 'python infra/helper.py build_fuzzers --sanitizer ' 208 f'{build.sanitizer} --engine {build.fuzzing_engine} --architecture ' 209 f'{build.architecture} {project.name}\n' + '*' * 80) 210 compile_step = { 211 'name': project.image, 212 'env': env, 213 'args': [ 214 'bash', 215 '-c', 216 # Remove /out to make sure there are non instrumented binaries. 217 # `cd /src && cd {workdir}` (where {workdir} is parsed from the 218 # Dockerfile). Container Builder overrides our workdir so we need 219 # to add this step to set it back. 220 (f'rm -r /out && cd /src && cd {project.workdir} && ' 221 f'mkdir -p {build.out} && compile || ' 222 f'(echo "{failure_msg}" && false)'), 223 ], 224 'id': get_id('compile', build), 225 } 226 if parallel: 227 maybe_add_parallel(compile_step, build_lib.get_srcmap_step_id(), parallel) 228 return compile_step 229 230 231def maybe_add_parallel(step, wait_for_id, parallel): 232 """Makes |step| run immediately after |wait_for_id| if |parallel|. Mutates 233 |step|.""" 234 if not parallel: 235 return 236 step['waitFor'] = wait_for_id 237 238 239def get_id(step_type, build): 240 """Returns a unique step id based on |step_type| and |build|. Useful for 241 parallelizing builds.""" 242 return (f'{step_type}-{build.fuzzing_engine}-{build.sanitizer}' 243 f'-{build.architecture}') 244 245 246def get_build_steps( # pylint: disable=too-many-locals, too-many-statements, too-many-branches, too-many-arguments 247 project_name, project_yaml_contents, dockerfile, image_project, 248 base_images_project, config): 249 """Returns build steps for project.""" 250 251 project = Project(project_name, project_yaml_contents, dockerfile, 252 image_project) 253 254 if project.disabled: 255 logging.info('Project "%s" is disabled.', project.name) 256 return [] 257 258 timestamp = get_datetime_now().strftime('%Y%m%d%H%M') 259 260 build_steps = build_lib.project_image_steps( 261 project.name, 262 project.image, 263 project.fuzzing_language, 264 branch=config.branch, 265 test_image_suffix=config.test_image_suffix) 266 267 # Sort engines to make AFL first to test if libFuzzer has an advantage in 268 # finding bugs first since it is generally built first. 269 for fuzzing_engine in sorted(project.fuzzing_engines): 270 for sanitizer in project.sanitizers: 271 for architecture in project.architectures: 272 build = Build(fuzzing_engine, sanitizer, architecture) 273 if not is_supported_configuration(build): 274 continue 275 276 env = get_env(project.fuzzing_language, build) 277 compile_step = get_compile_step(project, build, env, config.parallel) 278 build_steps.append(compile_step) 279 280 if project.run_tests: 281 failure_msg = ( 282 '*' * 80 + '\nBuild checks failed.\n' 283 'To reproduce, run:\n' 284 f'python infra/helper.py build_image {project.name}\n' 285 'python infra/helper.py build_fuzzers --sanitizer ' 286 f'{build.sanitizer} --engine {build.fuzzing_engine} ' 287 f'--architecture {build.architecture} {project.name}\n' 288 'python infra/helper.py check_build --sanitizer ' 289 f'{build.sanitizer} --engine {build.fuzzing_engine} ' 290 f'--architecture {build.architecture} {project.name}\n' + 291 '*' * 80) 292 # Test fuzz targets. 293 test_step = { 294 'name': 295 get_runner_image_name(base_images_project, 296 config.test_image_suffix), 297 'env': 298 env, 299 'args': [ 300 'bash', '-c', 301 f'test_all.py || (echo "{failure_msg}" && false)' 302 ], 303 'id': 304 get_id('build-check', build) 305 } 306 maybe_add_parallel(test_step, get_last_step_id(build_steps), 307 config.parallel) 308 build_steps.append(test_step) 309 310 if project.labels: 311 # Write target labels. 312 build_steps.append({ 313 'name': 314 project.image, 315 'env': 316 env, 317 'args': [ 318 '/usr/local/bin/write_labels.py', 319 json.dumps(project.labels), 320 build.out, 321 ], 322 }) 323 324 if build.sanitizer == 'dataflow' and build.fuzzing_engine == 'dataflow': 325 dataflow_steps = dataflow_post_build_steps(project.name, env, 326 base_images_project, 327 config.testing, 328 config.test_image_suffix) 329 if dataflow_steps: 330 build_steps.extend(dataflow_steps) 331 else: 332 sys.stderr.write('Skipping dataflow post build steps.\n') 333 334 build_steps.extend([ 335 # Generate targets list. 336 { 337 'name': 338 get_runner_image_name(base_images_project, 339 config.test_image_suffix), 340 'env': 341 env, 342 'args': [ 343 'bash', '-c', 344 f'targets_list > /workspace/{build.targets_list_filename}' 345 ], 346 } 347 ]) 348 upload_steps = get_upload_steps(project, build, timestamp, 349 base_images_project, config.testing) 350 build_steps.extend(upload_steps) 351 352 return build_steps 353 354 355def get_targets_list_upload_step(bucket, project, build, uploader_image): 356 """Returns the step to upload targets_list for |build| of |project| to 357 |bucket|.""" 358 targets_list_url = build_lib.get_signed_url( 359 build_lib.get_targets_list_url(bucket, project.name, build.sanitizer)) 360 return { 361 'name': uploader_image, 362 'args': [ 363 f'/workspace/{build.targets_list_filename}', 364 targets_list_url, 365 ], 366 } 367 368 369def get_uploader_image(base_images_project): 370 """Returns the uploader base image in |base_images_project|.""" 371 return f'gcr.io/{base_images_project}/uploader' 372 373 374def get_upload_steps(project, build, timestamp, base_images_project, testing): 375 """Returns the steps for uploading the fuzzer build specified by |project| and 376 |build|. Uses |timestamp| for naming the uploads. Uses |base_images_project| 377 and |testing| for determining which image to use for the upload.""" 378 bucket = build_lib.get_upload_bucket(build.fuzzing_engine, build.architecture, 379 testing) 380 stamped_name = '-'.join([project.name, build.sanitizer, timestamp]) 381 zip_file = stamped_name + '.zip' 382 upload_url = build_lib.get_signed_url( 383 build_lib.GCS_UPLOAD_URL_FORMAT.format(bucket, project.name, zip_file)) 384 stamped_srcmap_file = stamped_name + '.srcmap.json' 385 srcmap_url = build_lib.get_signed_url( 386 build_lib.GCS_UPLOAD_URL_FORMAT.format(bucket, project.name, 387 stamped_srcmap_file)) 388 latest_version_file = '-'.join( 389 [project.name, build.sanitizer, LATEST_VERSION_FILENAME]) 390 latest_version_url = build_lib.GCS_UPLOAD_URL_FORMAT.format( 391 bucket, project.name, latest_version_file) 392 latest_version_url = build_lib.get_signed_url( 393 latest_version_url, content_type=LATEST_VERSION_CONTENT_TYPE) 394 uploader_image = get_uploader_image(base_images_project) 395 396 upload_steps = [ 397 # Zip binaries. 398 { 399 'name': project.image, 400 'args': ['bash', '-c', f'cd {build.out} && zip -r {zip_file} *'], 401 }, 402 # Upload srcmap. 403 { 404 'name': uploader_image, 405 'args': [ 406 '/workspace/srcmap.json', 407 srcmap_url, 408 ], 409 }, 410 # Upload binaries. 411 { 412 'name': uploader_image, 413 'args': [ 414 os.path.join(build.out, zip_file), 415 upload_url, 416 ], 417 }, 418 # Upload targets list. 419 get_targets_list_upload_step(bucket, project, build, uploader_image), 420 # Upload the latest.version file. 421 build_lib.http_upload_step(zip_file, latest_version_url, 422 LATEST_VERSION_CONTENT_TYPE), 423 # Cleanup. 424 get_cleanup_step(project, build), 425 ] 426 return upload_steps 427 428 429def get_cleanup_step(project, build): 430 """Returns the step for cleaning up after doing |build| of |project|.""" 431 return { 432 'name': project.image, 433 'args': [ 434 'bash', 435 '-c', 436 'rm -r ' + build.out, 437 ], 438 } 439 440 441def get_runner_image_name(base_images_project, test_image_suffix): 442 """Returns the runner image that should be used, based on 443 |base_images_project|. Returns the testing image if |test_image_suffix|.""" 444 image = f'gcr.io/{base_images_project}/base-runner' 445 if test_image_suffix: 446 image += '-' + test_image_suffix 447 return image 448 449 450def dataflow_post_build_steps(project_name, env, base_images_project, testing, 451 test_image_suffix): 452 """Appends dataflow post build steps.""" 453 steps = build_lib.download_corpora_steps(project_name, testing) 454 if not steps: 455 return None 456 457 steps.append({ 458 'name': 459 get_runner_image_name(base_images_project, test_image_suffix), 460 'env': 461 env + [ 462 'COLLECT_DFT_TIMEOUT=2h', 463 'DFT_FILE_SIZE_LIMIT=65535', 464 'DFT_MIN_TIMEOUT=2.0', 465 'DFT_TIMEOUT_RANGE=6.0', 466 ], 467 'args': [ 468 'bash', '-c', 469 ('for f in /corpus/*.zip; do unzip -q $f -d ${f%%.*}; done && ' 470 'collect_dft || (echo "DFT collection failed." && false)') 471 ], 472 'volumes': [{ 473 'name': 'corpus', 474 'path': '/corpus' 475 }], 476 }) 477 return steps 478 479 480def get_logs_url(build_id, cloud_project='oss-fuzz'): 481 """Returns url where logs are displayed for the build.""" 482 return ('https://console.cloud.google.com/logs/viewer?' 483 f'resource=build%2Fbuild_id%2F{build_id}&project={cloud_project}') 484 485 486def get_gcb_url(build_id, cloud_project='oss-fuzz'): 487 """Returns url where logs are displayed for the build.""" 488 return (f'https://console.cloud.google.com/cloud-build/builds/{build_id}' 489 f'?project={cloud_project}') 490 491 492# pylint: disable=no-member 493def run_build(oss_fuzz_project, 494 build_steps, 495 credentials, 496 build_type, 497 cloud_project='oss-fuzz'): 498 """Run the build for given steps on cloud build. |build_steps| are the steps 499 to run. |credentials| are are used to authenticate to GCB and build in 500 |cloud_project|. |oss_fuzz_project| and |build_type| are used to tag the build 501 in GCB so the build can be queried for debugging purposes.""" 502 options = {} 503 if 'GCB_OPTIONS' in os.environ: 504 options = yaml.safe_load(os.environ['GCB_OPTIONS']) 505 else: 506 options = DEFAULT_GCB_OPTIONS 507 508 tags = [oss_fuzz_project + '-' + build_type, build_type, oss_fuzz_project] 509 build_body = { 510 'steps': build_steps, 511 'timeout': str(build_lib.BUILD_TIMEOUT) + 's', 512 'options': options, 513 'logsBucket': GCB_LOGS_BUCKET, 514 'tags': tags, 515 'queueTtl': str(QUEUE_TTL_SECONDS) + 's', 516 } 517 518 cloudbuild = cloud_build('cloudbuild', 519 'v1', 520 credentials=credentials, 521 cache_discovery=False) 522 build_info = cloudbuild.projects().builds().create(projectId=cloud_project, 523 body=build_body).execute() 524 build_id = build_info['metadata']['build']['id'] 525 526 logging.info('Build ID: %s', build_id) 527 logging.info('Logs: %s', get_logs_url(build_id, cloud_project)) 528 logging.info('Cloud build page: %s', get_gcb_url(build_id, cloud_project)) 529 return build_id 530 531 532def get_args(description): 533 """Parses command line arguments and returns them. Suitable for a build 534 script.""" 535 parser = argparse.ArgumentParser(sys.argv[0], description=description) 536 parser.add_argument('projects', help='Projects.', nargs='+') 537 parser.add_argument('--testing', 538 action='store_true', 539 required=False, 540 default=False, 541 help='Upload to testing buckets.') 542 parser.add_argument('--test-image-suffix', 543 required=False, 544 default=None, 545 help='Use testing base-images.') 546 parser.add_argument('--branch', 547 required=False, 548 default=None, 549 help='Use specified OSS-Fuzz branch.') 550 parser.add_argument('--parallel', 551 action='store_true', 552 required=False, 553 default=False, 554 help='Do builds in parallel.') 555 return parser.parse_args() 556 557 558def build_script_main(script_description, get_build_steps_func, build_type): 559 """Gets arguments from command line using |script_description| as helpstring 560 description. Gets build_steps using |get_build_steps_func| and then runs those 561 steps on GCB, tagging the builds with |build_type|. Returns 0 on success, 1 on 562 failure.""" 563 args = get_args(script_description) 564 logging.basicConfig(level=logging.INFO) 565 566 image_project = 'oss-fuzz' 567 base_images_project = 'oss-fuzz-base' 568 569 credentials = oauth2client.client.GoogleCredentials.get_application_default() 570 error = False 571 config = Config(args.testing, args.test_image_suffix, args.branch, 572 args.parallel) 573 for project_name in args.projects: 574 logging.info('Getting steps for: "%s".', project_name) 575 try: 576 project_yaml_contents, dockerfile_contents = get_project_data( 577 project_name) 578 except FileNotFoundError: 579 logging.error('Couldn\'t get project data. Skipping %s.', project_name) 580 error = True 581 continue 582 583 steps = get_build_steps_func(project_name, project_yaml_contents, 584 dockerfile_contents, image_project, 585 base_images_project, config) 586 if not steps: 587 logging.error('No steps. Skipping %s.', project_name) 588 error = True 589 continue 590 591 run_build(project_name, steps, credentials, build_type) 592 return 0 if not error else 1 593 594 595def main(): 596 """Build and run projects.""" 597 return build_script_main('Builds a project on GCB.', get_build_steps, 598 FUZZING_BUILD_TYPE) 599 600 601if __name__ == '__main__': 602 sys.exit(main()) 603