• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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