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 getting the configuration CIFuzz needs to run.""" 15 16import logging 17import enum 18import os 19import sys 20import json 21 22import environment 23 24# pylint: disable=wrong-import-position,import-error 25sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 26 27import constants 28 29RUN_FUZZERS_MODES = ['batch', 'ci', 'coverage', 'prune'] 30SANITIZERS = ['address', 'memory', 'undefined', 'coverage'] 31 32# TODO(metzman): Set these on config objects so there's one source of truth. 33DEFAULT_ENGINE = 'libfuzzer' 34DEFAULT_ARCHITECTURE = 'x86_64' 35 36# This module deals a lot with env variables. Many of these will be set by users 37# and others beyond CIFuzz's control. Thus, you should be careful about using 38# the environment.py helpers for getting env vars, since it can cause values 39# that should be interpreted as strings to be returned as other types (bools or 40# ints for example). The environment.py helpers should not be used for values 41# that are supposed to be strings. 42 43 44def _get_pr_ref(event): 45 if event == 'pull_request': 46 return os.getenv('GITHUB_REF') 47 return None 48 49 50def _get_sanitizer(): 51 return os.getenv('SANITIZER', constants.DEFAULT_SANITIZER).lower() 52 53 54def _is_dry_run(): 55 """Returns True if configured to do a dry run.""" 56 return environment.get_bool('DRY_RUN', False) 57 58 59def _get_language(): 60 """Returns the project language.""" 61 # Get language from environment. We took this approach because the convenience 62 # given to OSS-Fuzz users by not making them specify the language again (and 63 # getting it from the project.yaml) is outweighed by the complexity in 64 # implementing this. A lot of the complexity comes from our unittests not 65 # setting a proper projet at this point. 66 return os.getenv('LANGUAGE', constants.DEFAULT_LANGUAGE) 67 68 69# pylint: disable=too-few-public-methods,too-many-instance-attributes 70 71 72class BaseCiEnvironment: 73 """Base class for CiEnvironment subclasses.""" 74 75 @property 76 def workspace(self): 77 """Returns the workspace.""" 78 raise NotImplementedError('Child class must implment method.') 79 80 @property 81 def git_sha(self): 82 """Returns the Git SHA to diff against.""" 83 raise NotImplementedError('Child class must implment method.') 84 85 @property 86 def token(self): 87 """Returns the CI API token.""" 88 raise NotImplementedError('Child class must implment method.') 89 90 @property 91 def project_src_path(self): 92 """Returns the manually checked out path of the project's source if 93 specified or None.""" 94 95 path = os.getenv('PROJECT_SRC_PATH') 96 if not path: 97 logging.debug('No PROJECT_SRC_PATH.') 98 return path 99 100 logging.debug('PROJECT_SRC_PATH set: %s.', path) 101 return path 102 103 104class GenericCiEnvironment(BaseCiEnvironment): 105 """CI Environment for generic CI systems.""" 106 107 @property 108 def workspace(self): 109 """Returns the workspace.""" 110 return os.getenv('WORKSPACE') 111 112 @property 113 def git_sha(self): 114 """Returns the Git SHA to diff against.""" 115 return os.getenv('GIT_SHA') 116 117 @property 118 def token(self): 119 """Returns the CI API token.""" 120 return os.getenv('TOKEN') 121 122 @property 123 def project_repo_owner_and_name(self): 124 """Returns a tuple containing the project repo owner and None.""" 125 repository = os.getenv('REPOSITORY') 126 # Repo owner is a githubism. 127 return None, repository 128 129 130class GithubEnvironment(BaseCiEnvironment): 131 """CI environment for GitHub.""" 132 133 @property 134 def workspace(self): 135 """Returns the workspace.""" 136 return os.getenv('GITHUB_WORKSPACE') 137 138 @property 139 def git_sha(self): 140 """Returns the Git SHA to diff against.""" 141 return os.getenv('GITHUB_SHA') 142 143 @property 144 def token(self): 145 """Returns the CI API token.""" 146 return os.getenv('GITHUB_TOKEN') 147 148 @property 149 def project_src_path(self): 150 """Returns the manually checked out path of the project's source if 151 specified or None. The path returned is relative to |self.workspace| since 152 on github the checkout will be relative to there.""" 153 # On GitHub, they don't know the absolute path, it is relative to 154 # |workspace|. 155 project_src_path = super().project_src_path 156 if project_src_path is None: 157 return project_src_path 158 return os.path.join(self.workspace, project_src_path) 159 160 @property 161 def project_repo_owner_and_name(self): 162 """Returns a tuple containing the project repo owner and the name of the 163 repo.""" 164 # On GitHub this includes owner and repo name. 165 repository = os.getenv('GITHUB_REPOSITORY') 166 # Use os.path.split to split owner from repo. 167 return os.path.split(repository) 168 169 170class ConfigError(Exception): 171 """Error for invalid configuration.""" 172 173 174class BaseConfig: 175 """Object containing constant configuration for CIFuzz.""" 176 177 class Platform(enum.Enum): 178 """Enum representing the different platforms CIFuzz runs on.""" 179 EXTERNAL_GITHUB = 0 # Non-OSS-Fuzz on GitHub actions. 180 INTERNAL_GITHUB = 1 # OSS-Fuzz on GitHub actions. 181 INTERNAL_GENERIC_CI = 2 # OSS-Fuzz on any CI. 182 EXTERNAL_GENERIC_CI = 3 # Non-OSS-Fuzz on any CI. 183 184 def __init__(self): 185 # Need to set these before calling self.platform. 186 self._github_event_path = os.getenv('GITHUB_EVENT_PATH') 187 self.is_github = bool(self._github_event_path) 188 logging.debug('Is github: %s.', self.is_github) 189 self.oss_fuzz_project_name = os.getenv('OSS_FUZZ_PROJECT_NAME') 190 191 self._ci_env = _get_ci_environment(self.platform) 192 self.workspace = self._ci_env.workspace 193 194 self.project_repo_owner, self.project_repo_name = ( 195 self._ci_env.project_repo_owner_and_name) 196 197 # Check if failures should not be reported. 198 self.dry_run = _is_dry_run() 199 200 self.sanitizer = _get_sanitizer() 201 202 self.build_integration_path = ( 203 constants.DEFAULT_EXTERNAL_BUILD_INTEGRATION_PATH) 204 self.language = _get_language() 205 self.low_disk_space = environment.get_bool('LOW_DISK_SPACE', False) 206 207 self.token = self._ci_env.token 208 self.git_store_repo = os.environ.get('GIT_STORE_REPO') 209 self.git_store_branch = os.environ.get('GIT_STORE_BRANCH') 210 self.git_store_branch_coverage = os.environ.get('GIT_STORE_BRANCH_COVERAGE', 211 self.git_store_branch) 212 self.docker_in_docker = os.environ.get('DOCKER_IN_DOCKER') 213 214 # TODO(metzman): Fix tests to create valid configurations and get rid of 215 # CIFUZZ_TEST here and in presubmit.py. 216 if not os.getenv('CIFUZZ_TEST') and not self.validate(): 217 raise ConfigError('Invalid Configuration.') 218 219 def validate(self): 220 """Returns False if the configuration is invalid.""" 221 # Do validation here so that unittests don't need to make a fully-valid 222 # config. 223 if not self.workspace: 224 logging.error('Must set WORKSPACE.') 225 return False 226 227 if self.sanitizer not in SANITIZERS: 228 logging.error('Invalid SANITIZER: %s. Must be one of: %s.', 229 self.sanitizer, SANITIZERS) 230 return False 231 232 if self.language not in constants.LANGUAGES: 233 logging.error('Invalid LANGUAGE: %s. Must be one of: %s.', self.language, 234 constants.LANGUAGES) 235 return False 236 237 return True 238 239 @property 240 def is_internal(self): 241 """Returns True if this is an OSS-Fuzz project.""" 242 return bool(self.oss_fuzz_project_name) 243 244 @property 245 def platform(self): 246 """Returns the platform CIFuzz is runnning on.""" 247 if not self.is_internal: 248 if not self.is_github: 249 return self.Platform.EXTERNAL_GENERIC_CI 250 return self.Platform.EXTERNAL_GITHUB 251 252 if self.is_github: 253 return self.Platform.INTERNAL_GITHUB 254 return self.Platform.INTERNAL_GENERIC_CI 255 256 @property 257 def is_coverage(self): 258 """Returns True if this CIFuzz run (building fuzzers and running them) for 259 generating a coverage report.""" 260 return self.sanitizer == 'coverage' 261 262 263_CI_ENVIRONMENT_MAPPING = { 264 BaseConfig.Platform.EXTERNAL_GITHUB: GithubEnvironment, 265 BaseConfig.Platform.INTERNAL_GITHUB: GithubEnvironment, 266 BaseConfig.Platform.INTERNAL_GENERIC_CI: GenericCiEnvironment, 267 BaseConfig.Platform.EXTERNAL_GENERIC_CI: GenericCiEnvironment, 268} 269 270 271def _get_ci_environment(platform): 272 """Returns the CI environment object for |platform|.""" 273 return _CI_ENVIRONMENT_MAPPING[platform]() 274 275 276class RunFuzzersConfig(BaseConfig): 277 """Class containing constant configuration for running fuzzers in CIFuzz.""" 278 279 def __init__(self): 280 super().__init__() 281 # TODO(metzman): Pick a better default for pruning. 282 self.fuzz_seconds = int(os.environ.get('FUZZ_SECONDS', 600)) 283 self.run_fuzzers_mode = os.environ.get('RUN_FUZZERS_MODE', 'ci').lower() 284 if self.is_coverage: 285 self.run_fuzzers_mode = 'coverage' 286 287 self.report_unreproducible_crashes = environment.get_bool( 288 'REPORT_UNREPRODUCIBLE_CRASHES', False) 289 290 # TODO(metzman): Fix tests to create valid configurations and get rid of 291 # CIFUZZ_TEST here and in presubmit.py. 292 if not os.getenv('CIFUZZ_TEST') and not self._run_config_validate(): 293 raise ConfigError('Invalid Run Configuration.') 294 295 def _run_config_validate(self): 296 """Do extra validation on RunFuzzersConfig.__init__(). Do not name this 297 validate or else it will be called when using the parent's __init__ and will 298 fail. Returns True if valid.""" 299 if self.run_fuzzers_mode not in RUN_FUZZERS_MODES: 300 logging.error('Invalid RUN_FUZZERS_MODE: %s. Must be one of %s.', 301 self.run_fuzzers_mode, RUN_FUZZERS_MODES) 302 return False 303 304 return True 305 306 307class BuildFuzzersConfig(BaseConfig): 308 """Class containing constant configuration for building fuzzers in CIFuzz.""" 309 310 def _get_config_from_event_path(self, event): 311 if not self._github_event_path: 312 return 313 with open(self._github_event_path, encoding='utf-8') as file_handle: 314 event_data = json.load(file_handle) 315 if event == 'push': 316 self.base_commit = event_data['before'] 317 logging.debug('base_commit: %s', self.base_commit) 318 elif event == 'pull_request': 319 self.pr_ref = f'refs/pull/{event_data["pull_request"]["number"]}/merge' 320 logging.debug('pr_ref: %s', self.pr_ref) 321 322 self.git_url = event_data['repository']['html_url'] 323 324 def __init__(self): 325 """Get the configuration from CIFuzz from the environment. These variables 326 are set by GitHub or the user.""" 327 super().__init__() 328 self.commit_sha = self._ci_env.git_sha 329 event = os.getenv('GITHUB_EVENT_NAME') 330 331 self.pr_ref = None 332 self.git_url = None 333 self.base_commit = None 334 self._get_config_from_event_path(event) 335 336 self.base_ref = os.getenv('GITHUB_BASE_REF') 337 self.project_src_path = self._ci_env.project_src_path 338 339 self.allowed_broken_targets_percentage = os.getenv( 340 'ALLOWED_BROKEN_TARGETS_PERCENTAGE') 341 self.bad_build_check = environment.get_bool('BAD_BUILD_CHECK', True) 342 # pylint: disable=consider-using-ternary 343 self.keep_unaffected_fuzz_targets = ( 344 # Not from a commit or PR. 345 (not self.base_ref and not self.base_commit) or 346 environment.get_bool('KEEP_UNAFFECTED_FUZZERS')) 347 self.upload_build = environment.get_bool('UPLOAD_BUILD', False) 348 if self.upload_build: 349 logging.info('Keeping all fuzzers because we are uploading build.') 350 self.keep_unaffected_fuzz_targets = True 351 352 if self.sanitizer == 'coverage': 353 self.keep_unaffected_fuzz_targets = True 354 self.bad_build_check = False 355