1# Copyright 2021 Google LLC 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"""Module for interacting with the ClusterFuzz deployment.""" 15import logging 16import os 17import sys 18import urllib.error 19import urllib.request 20 21import config_utils 22import continuous_integration 23import filestore 24import filestore_utils 25import http_utils 26import get_coverage 27import repo_manager 28 29# pylint: disable=wrong-import-position,import-error 30sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 31import utils 32 33 34class BaseClusterFuzzDeployment: 35 """Base class for ClusterFuzz deployments.""" 36 37 def __init__(self, config, workspace): 38 self.config = config 39 self.workspace = workspace 40 self.ci_system = continuous_integration.get_ci(config) 41 42 def download_latest_build(self): 43 """Downloads the latest build from ClusterFuzz. 44 45 Returns: 46 A path to where the OSS-Fuzz build was stored, or None if it wasn't. 47 """ 48 raise NotImplementedError('Child class must implement method.') 49 50 def upload_build(self, commit): 51 """Uploads the build with the given commit sha to the filestore.""" 52 raise NotImplementedError('Child class must implement method.') 53 54 def download_corpus(self, target_name, corpus_dir): 55 """Downloads the corpus for |target_name| from ClusterFuzz to |corpus_dir|. 56 57 Returns: 58 A path to where the OSS-Fuzz build was stored, or None if it wasn't. 59 """ 60 raise NotImplementedError('Child class must implement method.') 61 62 def upload_crashes(self): 63 """Uploads crashes in |crashes_dir| to filestore.""" 64 raise NotImplementedError('Child class must implement method.') 65 66 def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument 67 """Uploads the corpus for |target_name| to filestore.""" 68 raise NotImplementedError('Child class must implement method.') 69 70 def upload_coverage(self): 71 """Uploads the coverage report to the filestore.""" 72 raise NotImplementedError('Child class must implement method.') 73 74 def get_coverage(self, repo_path): 75 """Returns the project coverage object for the project.""" 76 raise NotImplementedError('Child class must implement method.') 77 78 79def _make_empty_dir_if_nonexistent(path): 80 """Makes an empty directory at |path| if it does not exist.""" 81 os.makedirs(path, exist_ok=True) 82 83 84class ClusterFuzzLite(BaseClusterFuzzDeployment): 85 """Class representing a deployment of ClusterFuzzLite.""" 86 87 COVERAGE_NAME = 'latest' 88 LATEST_BUILD_WINDOW = 3 89 90 def __init__(self, config, workspace): 91 super().__init__(config, workspace) 92 self.filestore = filestore_utils.get_filestore(self.config) 93 94 def download_latest_build(self): 95 if os.path.exists(self.workspace.clusterfuzz_build): 96 # This path is necessary because download_latest_build can be called 97 # multiple times.That is the case because it is called only when we need 98 # to see if a bug is novel, i.e. until we want to check a bug is novel we 99 # don't want to waste time calling this, but therefore this method can be 100 # called if multiple bugs are found. 101 return self.workspace.clusterfuzz_build 102 103 repo_dir = self.ci_system.repo_dir() 104 if not repo_dir: 105 raise RuntimeError('Repo checkout does not exist.') 106 107 _make_empty_dir_if_nonexistent(self.workspace.clusterfuzz_build) 108 repo = repo_manager.RepoManager(repo_dir) 109 110 # Builds are stored by commit, so try the latest |LATEST_BUILD_WINDOW| 111 # commits before the current. 112 # TODO(ochang): If API usage becomes an issue, this can be optimized by the 113 # filestore accepting a list of filenames to try. 114 for old_commit in repo.get_commit_list('HEAD^', 115 limit=self.LATEST_BUILD_WINDOW): 116 logging.info('Trying to downloading previous build %s.', old_commit) 117 build_name = self._get_build_name(old_commit) 118 try: 119 if self.filestore.download_build(build_name, 120 self.workspace.clusterfuzz_build): 121 logging.info('Done downloading previus build.') 122 return self.workspace.clusterfuzz_build 123 124 logging.info('Build for %s does not exist.', old_commit) 125 except Exception as err: # pylint: disable=broad-except 126 logging.error('Could not download build for %s because of: %s', 127 old_commit, err) 128 129 return None 130 131 def download_corpus(self, target_name, corpus_dir): 132 _make_empty_dir_if_nonexistent(corpus_dir) 133 logging.info('Downloading corpus for %s to %s.', target_name, corpus_dir) 134 corpus_name = self._get_corpus_name(target_name) 135 try: 136 self.filestore.download_corpus(corpus_name, corpus_dir) 137 logging.info('Done downloading corpus. Contains %d elements.', 138 len(os.listdir(corpus_dir))) 139 except Exception as err: # pylint: disable=broad-except 140 logging.error('Failed to download corpus for target: %s. Error: %s', 141 target_name, str(err)) 142 return corpus_dir 143 144 def _get_build_name(self, name): 145 return f'{self.config.sanitizer}-{name}' 146 147 def _get_corpus_name(self, target_name): # pylint: disable=no-self-use 148 """Returns the name of the corpus artifact.""" 149 return target_name 150 151 def _get_crashes_artifact_name(self): # pylint: disable=no-self-use 152 """Returns the name of the crashes artifact.""" 153 return 'current' 154 155 def upload_corpus(self, target_name, corpus_dir, replace=False): 156 """Upload the corpus produced by |target_name|.""" 157 logging.info('Uploading corpus in %s for %s.', corpus_dir, target_name) 158 name = self._get_corpus_name(target_name) 159 try: 160 self.filestore.upload_corpus(name, corpus_dir, replace=replace) 161 logging.info('Done uploading corpus.') 162 except Exception as error: # pylint: disable=broad-except 163 logging.error('Failed to upload corpus for target: %s. Error: %s.', 164 target_name, error) 165 166 def upload_build(self, commit): 167 """Upload the build produced by CIFuzz as the latest build.""" 168 logging.info('Uploading latest build in %s.', self.workspace.out) 169 build_name = self._get_build_name(commit) 170 try: 171 result = self.filestore.upload_build(build_name, self.workspace.out) 172 logging.info('Done uploading latest build.') 173 return result 174 except Exception as error: # pylint: disable=broad-except 175 logging.error('Failed to upload latest build: %s. Error: %s', 176 self.workspace.out, error) 177 178 def upload_crashes(self): 179 """Uploads crashes.""" 180 if not os.listdir(self.workspace.artifacts): 181 logging.info('No crashes in %s. Not uploading.', self.workspace.artifacts) 182 return 183 184 crashes_artifact_name = self._get_crashes_artifact_name() 185 186 logging.info('Uploading crashes in %s.', self.workspace.artifacts) 187 try: 188 self.filestore.upload_crashes(crashes_artifact_name, 189 self.workspace.artifacts) 190 logging.info('Done uploading crashes.') 191 except Exception as error: # pylint: disable=broad-except 192 logging.error('Failed to upload crashes. Error: %s', error) 193 194 def upload_coverage(self): 195 """Uploads the coverage report to the filestore.""" 196 self.filestore.upload_coverage(self.COVERAGE_NAME, 197 self.workspace.coverage_report) 198 199 def get_coverage(self, repo_path): 200 """Returns the project coverage object for the project.""" 201 try: 202 if not self.filestore.download_coverage( 203 self.COVERAGE_NAME, self.workspace.clusterfuzz_coverage): 204 logging.error('Could not download coverage.') 205 return None 206 return get_coverage.FilesystemCoverage( 207 repo_path, self.workspace.clusterfuzz_coverage) 208 except (get_coverage.CoverageError, filestore.FilestoreError): 209 logging.error('Could not get coverage.') 210 return None 211 212 213class OSSFuzz(BaseClusterFuzzDeployment): 214 """The OSS-Fuzz ClusterFuzz deployment.""" 215 216 # Location of clusterfuzz builds on GCS. 217 CLUSTERFUZZ_BUILDS = 'clusterfuzz-builds' 218 219 # Zip file name containing the corpus. 220 CORPUS_ZIP_NAME = 'public.zip' 221 222 def get_latest_build_name(self): 223 """Gets the name of the latest OSS-Fuzz build of a project. 224 225 Returns: 226 A string with the latest build version or None. 227 """ 228 version_file = ( 229 f'{self.config.oss_fuzz_project_name}-{self.config.sanitizer}' 230 '-latest.version') 231 version_url = utils.url_join(utils.GCS_BASE_URL, self.CLUSTERFUZZ_BUILDS, 232 self.config.oss_fuzz_project_name, 233 version_file) 234 try: 235 response = urllib.request.urlopen(version_url) 236 except urllib.error.HTTPError: 237 logging.error('Error getting latest build version for %s from: %s.', 238 self.config.oss_fuzz_project_name, version_url) 239 return None 240 return response.read().decode() 241 242 def download_latest_build(self): 243 """Downloads the latest OSS-Fuzz build from GCS. 244 245 Returns: 246 A path to where the OSS-Fuzz build was stored, or None if it wasn't. 247 """ 248 if os.path.exists(self.workspace.clusterfuzz_build): 249 # This function can be called multiple times, don't download the build 250 # again. 251 return self.workspace.clusterfuzz_build 252 253 _make_empty_dir_if_nonexistent(self.workspace.clusterfuzz_build) 254 255 latest_build_name = self.get_latest_build_name() 256 if not latest_build_name: 257 return None 258 259 logging.info('Downloading latest build.') 260 oss_fuzz_build_url = utils.url_join(utils.GCS_BASE_URL, 261 self.CLUSTERFUZZ_BUILDS, 262 self.config.oss_fuzz_project_name, 263 latest_build_name) 264 if http_utils.download_and_unpack_zip(oss_fuzz_build_url, 265 self.workspace.clusterfuzz_build): 266 logging.info('Done downloading latest build.') 267 return self.workspace.clusterfuzz_build 268 269 return None 270 271 def upload_build(self, commit): # pylint: disable=no-self-use 272 """Noop Implementation of upload_build.""" 273 logging.info('Not uploading latest build because on OSS-Fuzz.') 274 275 def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument 276 """Noop Implementation of upload_corpus.""" 277 logging.info('Not uploading corpus because on OSS-Fuzz.') 278 279 def upload_crashes(self): # pylint: disable=no-self-use 280 """Noop Implementation of upload_crashes.""" 281 logging.info('Not uploading crashes because on OSS-Fuzz.') 282 283 def download_corpus(self, target_name, corpus_dir): 284 """Downloads the latest OSS-Fuzz corpus for the target. 285 286 Returns: 287 The local path to to corpus or None if download failed. 288 """ 289 _make_empty_dir_if_nonexistent(corpus_dir) 290 project_qualified_fuzz_target_name = target_name 291 qualified_name_prefix = self.config.oss_fuzz_project_name + '_' 292 if not target_name.startswith(qualified_name_prefix): 293 project_qualified_fuzz_target_name = qualified_name_prefix + target_name 294 295 corpus_url = (f'{utils.GCS_BASE_URL}{self.config.oss_fuzz_project_name}' 296 '-backup.clusterfuzz-external.appspot.com/corpus/' 297 f'libFuzzer/{project_qualified_fuzz_target_name}/' 298 f'{self.CORPUS_ZIP_NAME}') 299 300 if not http_utils.download_and_unpack_zip(corpus_url, corpus_dir): 301 logging.warning('Failed to download corpus for %s.', target_name) 302 return corpus_dir 303 304 def upload_coverage(self): 305 """Noop Implementation of upload_coverage_report.""" 306 logging.info('Not uploading coverage report because on OSS-Fuzz.') 307 308 def get_coverage(self, repo_path): 309 """Returns the project coverage object for the project.""" 310 try: 311 return get_coverage.OSSFuzzCoverage(repo_path, 312 self.config.oss_fuzz_project_name) 313 except get_coverage.CoverageError: 314 return None 315 316 317class NoClusterFuzzDeployment(BaseClusterFuzzDeployment): 318 """ClusterFuzzDeployment implementation used when there is no deployment of 319 ClusterFuzz to use.""" 320 321 def upload_build(self, commit): # pylint: disable=no-self-use 322 """Noop Implementation of upload_build.""" 323 logging.info('Not uploading latest build because no ClusterFuzz ' 324 'deployment.') 325 326 def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument 327 """Noop Implementation of upload_corpus.""" 328 logging.info('Not uploading corpus because no ClusterFuzz deployment.') 329 330 def upload_crashes(self): # pylint: disable=no-self-use 331 """Noop Implementation of upload_crashes.""" 332 logging.info('Not uploading crashes because no ClusterFuzz deployment.') 333 334 def download_corpus(self, target_name, corpus_dir): 335 """Noop Implementation of download_corpus.""" 336 logging.info('Not downloading corpus because no ClusterFuzz deployment.') 337 return _make_empty_dir_if_nonexistent(corpus_dir) 338 339 def download_latest_build(self): # pylint: disable=no-self-use 340 """Noop Implementation of download_latest_build.""" 341 logging.info( 342 'Not downloading latest build because no ClusterFuzz deployment.') 343 344 def upload_coverage(self): 345 """Noop Implementation of upload_coverage.""" 346 logging.info( 347 'Not uploading coverage report because no ClusterFuzz deployment.') 348 349 def get_coverage(self, repo_path): 350 """Noop Implementation of get_coverage.""" 351 logging.info( 352 'Not getting project coverage because no ClusterFuzz deployment.') 353 354 355_PLATFORM_CLUSTERFUZZ_DEPLOYMENT_MAPPING = { 356 config_utils.BaseConfig.Platform.INTERNAL_GENERIC_CI: 357 OSSFuzz, 358 config_utils.BaseConfig.Platform.INTERNAL_GITHUB: 359 OSSFuzz, 360 config_utils.BaseConfig.Platform.EXTERNAL_GENERIC_CI: 361 NoClusterFuzzDeployment, 362 config_utils.BaseConfig.Platform.EXTERNAL_GITHUB: 363 ClusterFuzzLite, 364} 365 366 367def get_clusterfuzz_deployment(config, workspace): 368 """Returns object reprsenting deployment of ClusterFuzz used by |config|.""" 369 deployment_cls = _PLATFORM_CLUSTERFUZZ_DEPLOYMENT_MAPPING[config.platform] 370 result = deployment_cls(config, workspace) 371 logging.info('ClusterFuzzDeployment: %s.', result) 372 return result 373