diff --git a/README.rst b/README.rst index a6df7f5e..7009f16c 100755 --- a/README.rst +++ b/README.rst @@ -973,6 +973,18 @@ container), you must pass the --publish-ports argument to buildrunner. This must never be used on a shared server such as a build server as it could cause port mapping conflicts. +Image Security Scans +==================== + +Pushed docker images may be automatically scanned for vulnerabilities using the ``security-scan`` +global configuration options or the ``--security-scan-*`` command line options that may override +the global configuration. Just set ``security-scan.enabled`` to true to enable automatic scans. + +The ``max-score-threshold`` may also be configured to fail the build if the max score of the +detected vulnerabilities is greater than or equal to the ``max-score-threshold`` value. + +Any detected vulnerabilities are added to the ``artifacts.json`` file per Docker image platform, +along with the detected maximum vulnerability score. Remote Builds (the 'remote' step attribute) =========================================== diff --git a/buildrunner/__init__.py b/buildrunner/__init__.py index 1d50999f..da01bb02 100644 --- a/buildrunner/__init__.py +++ b/buildrunner/__init__.py @@ -80,7 +80,7 @@ def __init__( docker_timeout: int, local_images: bool, platform: Optional[str], - disable_multi_platform: Optional[bool], + global_config_overrides: dict, ): # pylint: disable=too-many-statements,too-many-branches,too-many-locals,too-many-arguments self.build_dir = build_dir self.build_results_dir = build_results_dir @@ -135,15 +135,10 @@ def __init__( log_generated_files=self.log_generated_files, build_time=self.build_time, tmp_files=self.tmp_files, + global_config_overrides=global_config_overrides, ) buildrunner_config = BuildRunnerConfig.get_instance() - self.disable_multi_platform = ( - buildrunner_config.global_config.disable_multi_platform - ) - if disable_multi_platform is not None: - self.disable_multi_platform = disable_multi_platform - # cleanup local cache if self.cleanup_cache: self.clean_cache() @@ -328,18 +323,22 @@ def _exit_message(self, exit_explanation): Determine the exit message and output to the log. """ if self.exit_code: - exit_message = "\nBuild ERROR." + exit_message = "Build ERROR." + log_method = self.log.error else: - exit_message = "\nBuild SUCCESS." + exit_message = "Build SUCCESS." + log_method = self.log.info if self.log: if exit_explanation: - self.log.write("\n" + exit_explanation + "\n") - self.log.write(exit_message + "\n") + self.log.info("") + log_method(exit_explanation) + self.log.info("") + log_method(exit_message) else: if exit_explanation: print(f"\n{exit_explanation}") - print(exit_message) + print(f"\n{exit_message}") def run(self): # pylint: disable=too-many-statements,too-many-branches,too-many-locals """ @@ -355,7 +354,6 @@ def run(self): # pylint: disable=too-many-statements,too-many-branches,too-many docker_registry=buildrunner_config.global_config.docker_registry, build_registry=buildrunner_config.global_config.build_registry, temp_dir=buildrunner_config.global_config.temp_dir, - disable_multi_platform=self.disable_multi_platform, platform_builders=buildrunner_config.global_config.platform_builders, cache_builders=buildrunner_config.global_config.docker_build_cache.builders, cache_from=buildrunner_config.global_config.docker_build_cache.from_config, @@ -449,15 +447,12 @@ def run(self): # pylint: disable=too-many-statements,too-many-branches,too-many self.log.write("\nPush not requested\n") except BuildRunnerConfigurationError as brce: - print("config error") exit_explanation = str(brce) self.exit_code = os.EX_CONFIG except BuildRunnerProcessingError as brpe: - print("processing error") exit_explanation = str(brpe) self.exit_code = 1 except requests.exceptions.ConnectionError as rce: - print("connection error") print(str(rce)) exit_explanation = ( "Error communicating with the remote host.\n\tCheck that the " diff --git a/buildrunner/cli.py b/buildrunner/cli.py index be7d4d8f..7c1b182f 100644 --- a/buildrunner/cli.py +++ b/buildrunner/cli.py @@ -10,6 +10,9 @@ import os import shutil import sys +from typing import Optional + +import yaml from . import ( __version__, @@ -209,6 +212,38 @@ def parse_args(argv): help="overrides the 'platforms' configuration and global config; to disable multi-platform builds", ) + # Security scan config + parser.add_argument( + "--security-scan-enabled", + default=None, + choices=["true", "false"], + dest="security_scan_enabled", + help="overrides the security-scan.enabled global configuration parameter", + ) + parser.add_argument( + "--security-scan-scanner", + required=False, + choices=["trivy"], + dest="security_scan_scanner", + help="overrides the security-scan.scanner global configuration parameter", + ) + parser.add_argument( + "--security-scan-version", + dest="security_scan_version", + help="overrides the security-scan.version global configuration parameter", + ) + parser.add_argument( + "--security-scan-config-file", + dest="security_scan_config_file", + help="overrides the security-scan.config global configuration parameter by loading YAML data from the file", + ) + parser.add_argument( + "--security-scan-max-score-threshold", + type=float, + dest="security_scan_max_score_threshold", + help="overrides the security-scan.max-score-threshold global configuration parameter", + ) + args = parser.parse_args(argv[1:]) # Set build results dir if not set @@ -260,6 +295,70 @@ def _create_results_dir(cleanup_step_artifacts: bool, build_results_dir: str) -> sys.exit(os.EX_UNAVAILABLE) +def _load_security_scan_config_file(config_file: Optional[str]) -> Optional[dict]: + if not config_file: + return None + if not os.path.exists(config_file): + sys.stderr.write( + f"ERROR: The specified security scan config file ({config_file}) could not be found" + ) + sys.exit(os.EX_CONFIG) + try: + with open(config_file, "r", encoding="utf8") as fobj: + data = yaml.safe_load(fobj) + if not data or not isinstance(data, dict): + sys.stderr.write( + f"ERROR: The specified security scan config file ({config_file}) must contain a dictionary" + ) + sys.exit(os.EX_CONFIG) + return data + except Exception as exc: + sys.stderr.write( + f"ERROR: The specified security scan config file ({config_file}) could not be loaded: {exc}" + ) + sys.exit(os.EX_CONFIG) + + +def _get_security_scan_options(args: argparse.Namespace) -> dict: + security_scan_config = { + "enabled": _get_true_value(args.security_scan_enabled), + "scanner": args.security_scan_scanner, + "version": args.security_scan_version, + "config": _load_security_scan_config_file(args.security_scan_config_file), + "max-score-threshold": args.security_scan_max_score_threshold, + } + final_config = { + key: value for key, value in security_scan_config.items() if value is not None + } + if final_config: + return {"security-scan": final_config} + return {} + + +def _get_global_config_overrides(args: argparse.Namespace) -> dict: + """ + Creates a dictionary of overrides to be deeply merged into the loaded global config file(s) data. + Note that these field names must match exact what is stored in the global config file(s) and + that any fields listed here will override configured values. In other words, undefined/none values + should be filtered from the return data to prevent overriding + :param args: the parsed CLI args + :return: the overrides (if any specified) + """ + overrides = {} + if args.disable_multi_platform is not None: + overrides["disable-multi-platform"] = _get_true_value( + args.disable_multi_platform + ) + return { + **overrides, + **_get_security_scan_options(args), + } + + +def _get_true_value(value: Optional[str]) -> Optional[bool]: + return None if value is None else value == "true" + + def initialize_br(args: argparse.Namespace) -> BuildRunner: _create_results_dir(not args.keep_step_artifacts, args.build_results_dir) loggers.initialize_root_logger( @@ -295,9 +394,7 @@ def initialize_br(args: argparse.Namespace) -> BuildRunner: docker_timeout=args.docker_timeout, local_images=args.local_images, platform=args.platform, - disable_multi_platform=None - if args.disable_multi_platform is None - else args.disable_multi_platform == "true", + global_config_overrides=_get_global_config_overrides(args), ) diff --git a/buildrunner/config/__init__.py b/buildrunner/config/__init__.py index 516c6b64..a5ead56b 100644 --- a/buildrunner/config/__init__.py +++ b/buildrunner/config/__init__.py @@ -70,6 +70,7 @@ def __init__( run_config_file: Optional[str], log_generated_files: bool, build_time: int, + global_config_overrides: dict, # May be passed in to add temporary files to this list as they are created tmp_files: Optional[List[str]] = None, # Used only from CLI commands that do not need a run config @@ -84,7 +85,9 @@ def __init__( self.build_time = epoch_time() self.tmp_files = tmp_files - self.global_config = self._load_global_config(global_config_file) + self.global_config = self._load_global_config( + global_config_file, global_config_overrides + ) self.env = self._load_env( push, build_number=build_number, @@ -96,7 +99,9 @@ def __init__( self._load_run_config(run_config_file) if load_run_config else None ) - def _load_global_config(self, global_config_file: Optional[str]) -> GlobalConfig: + def _load_global_config( + self, global_config_file: Optional[str], global_config_overrides: dict + ) -> GlobalConfig: # load global configuration gc_files = DEFAULT_GLOBAL_CONFIG_FILES[:] gc_files.append(global_config_file or f"{self.build_dir}/.buildrunner.yaml") @@ -109,6 +114,7 @@ def _load_global_config(self, global_config_file: Optional[str]) -> GlobalConfig **load_global_config_files( build_time=self.build_time, global_config_files=abs_gc_files, + global_config_overrides=global_config_overrides, ) ) if errors: diff --git a/buildrunner/config/loader.py b/buildrunner/config/loader.py index be7a47b1..1192c05f 100644 --- a/buildrunner/config/loader.py +++ b/buildrunner/config/loader.py @@ -86,7 +86,7 @@ def _add_default_tag_to_tags(config: Union[str, dict], default_tag: str) -> dict return config -def _set_default_tag(config, default_tag) -> dict: +def _set_default_tag(config: dict, default_tag: str) -> dict: """ Set default tag if not set for each image @@ -312,10 +312,25 @@ def load_run_file( return config_data +def _deep_merge_dicts(a_dict: dict, b_dict: dict, path=None) -> dict: + if path is None: + path = [] + for key in b_dict: + if key in a_dict: + if isinstance(a_dict[key], dict) and isinstance(b_dict[key], dict): + _deep_merge_dicts(a_dict[key], b_dict[key], path + [str(key)]) + elif a_dict[key] != b_dict[key]: + a_dict[key] = b_dict[key] + else: + a_dict[key] = b_dict[key] + return a_dict + + def load_global_config_files( *, build_time: int, global_config_files: List[str], + global_config_overrides: dict, ) -> dict: """ Load global config files templating them with Jinja and parsing the YAML. @@ -376,9 +391,6 @@ def load_global_config_files( ) current_context["local-files"] = scrubbed_local_files - # Merge local-files from each file - if "local-files" in context and "local-files" in current_context: - context["local-files"].update(current_context.pop("local-files")) - context.update(current_context) + _deep_merge_dicts(context, current_context) - return context + return _deep_merge_dicts(context, global_config_overrides) diff --git a/buildrunner/config/models.py b/buildrunner/config/models.py index 67a6aacd..f5da7cd7 100644 --- a/buildrunner/config/models.py +++ b/buildrunner/config/models.py @@ -12,7 +12,6 @@ from pydantic import BaseModel, Field, field_validator, ValidationError -from buildrunner.docker import multiplatform_image_builder from .models_step import Step from .validation import ( get_validation_errors, @@ -23,6 +22,8 @@ DEFAULT_CACHES_ROOT = "~/.buildrunner/caches" +# Marker for using the local registry instead of an upstream registry +MP_LOCAL_REGISTRY = "local" class GithubModel(BaseModel, extra="forbid"): @@ -46,6 +47,20 @@ class DockerBuildCacheConfig(BaseModel, extra="forbid"): to_config: Optional[Union[dict, str]] = Field(None, alias="to") +class SecurityScanConfig(BaseModel, extra="forbid"): + enabled: bool = False + scanner: str = "trivy" + version: str = "latest" + # The local cache directory for the scanner (used if supported by the scanner) + cache_dir: Optional[str] = None + config: dict = { + "timeout": "20m", + # Do not error on vulnerabilities by default + "exit-code": 0, + } + max_score_threshold: Optional[float] = Field(None, alias="max-score-threshold") + + class GlobalConfig(BaseModel, extra="forbid"): """Top level global config model""" @@ -76,11 +91,14 @@ class GlobalConfig(BaseModel, extra="forbid"): alias="disable-multi-platform", default=None ) build_registry: Optional[str] = Field( - alias="build-registry", default=multiplatform_image_builder.LOCAL_REGISTRY + alias="build-registry", default=MP_LOCAL_REGISTRY ) platform_builders: Optional[Dict[str, str]] = Field( alias="platform-builders", default=None ) + security_scan: SecurityScanConfig = Field( + SecurityScanConfig(), alias="security-scan" + ) @field_validator("ssh_keys", mode="before") @classmethod diff --git a/buildrunner/docker/multiplatform_image_builder.py b/buildrunner/docker/multiplatform_image_builder.py index 458ae00e..7b82e069 100644 --- a/buildrunner/docker/multiplatform_image_builder.py +++ b/buildrunner/docker/multiplatform_image_builder.py @@ -20,14 +20,14 @@ from python_on_whales import docker from retry import retry +from buildrunner.config import BuildRunnerConfig +from buildrunner.config.models import MP_LOCAL_REGISTRY from buildrunner.docker import get_dockerfile from buildrunner.docker.image_info import BuiltImageInfo, BuiltTaggedImage LOGGER = logging.getLogger(__name__) OUTPUT_LINE = "-----------------------------------------------------------------" -# Marker for using the local registry instead of an upstream registry -LOCAL_REGISTRY = "local" IMAGE_PREFIX = "buildrunner-mp" PUSH_TIMEOUT = 300 @@ -72,9 +72,8 @@ class MultiplatformImageBuilder: # pylint: disable=too-many-instance-attributes def __init__( self, docker_registry: Optional[str] = None, - build_registry: Optional[str] = LOCAL_REGISTRY, + build_registry: Optional[str] = MP_LOCAL_REGISTRY, temp_dir: str = os.getcwd(), - disable_multi_platform: bool = False, platform_builders: Optional[Dict[str, str]] = None, cache_builders: Optional[List[str]] = None, cache_from: Optional[Union[dict, str]] = None, @@ -82,9 +81,8 @@ def __init__( ): self._docker_registry = docker_registry self._build_registry = build_registry - self._use_local_registry = build_registry == LOCAL_REGISTRY + self._use_local_registry = build_registry == MP_LOCAL_REGISTRY self._temp_dir = temp_dir - self._disable_multi_platform = disable_multi_platform self._platform_builders = platform_builders self._cache_builders = set(cache_builders if cache_builders else []) self._cache_from = cache_from @@ -106,14 +104,9 @@ def __exit__(self, exc_type, exc_value, traceback): if self._local_registry_is_running: self._stop_local_registry() - @property - def disable_multi_platform(self) -> bool: - """Returns true if multi-platform builds are disabled by configuration, false otherwise""" - return self._disable_multi_platform - def _build_registry_address(self) -> str: """Returns the address of the local registry""" - if self._build_registry == LOCAL_REGISTRY: + if self._build_registry == MP_LOCAL_REGISTRY: return f"{self._mp_registry_info.ip_addr}:{self._mp_registry_info.port}" return self._build_registry @@ -321,6 +314,23 @@ def _build_single_image( image_digest = self._get_image_digest(image_ref) queue.put((image_ref, image_digest)) + @staticmethod + def get_native_platform(): + """ + Retrieves the native platform for the current machine or a name that is similar + to the native platform used by Docker. + """ + host_system = python_platform.system() + host_machine = python_platform.machine() + + if host_system.lower() in ("darwin", "linux"): + host_system = "linux" + if host_machine.lower() == "x86_64": + host_machine = "amd64" + elif host_machine.lower() == "aarch64": + host_machine = "arm64" + return f"{host_system}/{host_machine}" + def _get_single_platform_to_build(self, platforms: List[str]) -> str: """Returns the platform to build for single platform flag""" @@ -329,13 +339,7 @@ def _get_single_platform_to_build(self, platforms: List[str]) -> str: len(platforms) > 0 ), f"Expected at least one platform, but got {len(platforms)}" - host_system = python_platform.system() - host_machine = python_platform.machine() - - if host_system.lower() in ("darwin", "linux"): - native_platform = f"linux/{host_machine}" - else: - native_platform = f"{host_system}/{host_machine}" + native_platform = self.get_native_platform() for curr_platform in platforms: if native_platform in curr_platform: @@ -401,7 +405,7 @@ def build_multiple_images( sanitized_name = f"{IMAGE_PREFIX}-unknown-node" repo = f"{self._build_registry_address()}/{sanitized_name}" - if self._disable_multi_platform: + if BuildRunnerConfig.get_instance().global_config.disable_multi_platform: platforms = [self._get_single_platform_to_build(platforms)] LOGGER.info(OUTPUT_LINE) LOGGER.info( diff --git a/buildrunner/docker/runner.py b/buildrunner/docker/runner.py index 23e7db07..50b0b629 100644 --- a/buildrunner/docker/runner.py +++ b/buildrunner/docker/runner.py @@ -122,6 +122,7 @@ def __init__(self, image_config, dockerd_url=None, log=None): def start( self, shell="/bin/sh", + entrypoint=None, working_dir=None, name=None, volumes=None, @@ -214,6 +215,9 @@ def start( privileged=privileged, ), } + if entrypoint: + kwargs["entrypoint"] = entrypoint + del kwargs["command"] if compare_version("1.10", self.docker_client.api_version) < 0: kwargs["dns"] = dns diff --git a/buildrunner/loggers.py b/buildrunner/loggers.py index 94e2f0fd..5a03d625 100644 --- a/buildrunner/loggers.py +++ b/buildrunner/loggers.py @@ -51,27 +51,32 @@ def get_build_log_file_path(build_results_dir: str) -> str: return os.path.join(build_results_dir, "build.log") +def _get_logger_format(no_log_color: bool, disable_timestamps: bool): + timestamp = "" if disable_timestamps else "%(asctime)s " + return CustomColoredFormatter( + f"%(log_color)s{timestamp}%(levelname)-8s %(message)s", + no_log_color, + ) + + def initialize_root_logger( debug: bool, no_log_color: bool, disable_timestamps: bool, build_results_dir: str ) -> None: logger = logging.getLogger() logger.setLevel(logging.DEBUG if debug else logging.INFO) - timestamp = "" if disable_timestamps else "%(asctime)s " - color_formatter = CustomColoredFormatter( - f"%(log_color)s{timestamp}%(levelname)-8s %(message)s", - no_log_color, - ) - no_color_formatter = color_formatter.clone(no_color=True) + console_formatter = _get_logger_format(no_log_color, disable_timestamps) + # The file formatter should always use no color and timestamps should be enabled + file_formatter = _get_logger_format(True, False) console_handler = logging.StreamHandler(sys.stdout) # Console logger should use colored output when specified by config - console_handler.setFormatter(color_formatter) + console_handler.setFormatter(console_formatter) file_handler = logging.FileHandler( get_build_log_file_path(build_results_dir), "w", encoding="utf8" ) # The build log should never use colored output - file_handler.setFormatter(no_color_formatter) + file_handler.setFormatter(file_formatter) logger.handlers.clear() logger.addHandler(console_handler) logger.addHandler(file_handler) diff --git a/buildrunner/steprunner/tasks/build.py b/buildrunner/steprunner/tasks/build.py index d8a9de5c..f93c4ef7 100644 --- a/buildrunner/steprunner/tasks/build.py +++ b/buildrunner/steprunner/tasks/build.py @@ -1,5 +1,5 @@ """ -Copyright 2021 Adobe +Copyright 2024 Adobe All Rights Reserved. NOTICE: Adobe permits you to use, modify, and distribute this file in accordance @@ -196,7 +196,8 @@ def run(self, context): "Cannot find a Dockerfile in the given path " "or inject configurations" ) - docker_registry = BuildRunnerConfig.get_instance().global_config.docker_registry + buildrunner_config = BuildRunnerConfig.get_instance() + docker_registry = buildrunner_config.global_config.docker_registry self.step_runner.log.write("Running docker build\n") builder = DockerBuilder( self.path, @@ -218,7 +219,7 @@ def run(self, context): num_platforms = len(self.platforms) - if self.step_runner.multi_platform.disable_multi_platform: + if buildrunner_config.global_config.disable_multi_platform: num_platforms = 1 num_built_platforms = len(built_image.platforms) diff --git a/buildrunner/steprunner/tasks/push.py b/buildrunner/steprunner/tasks/push.py index 9cd67945..0eb771d7 100644 --- a/buildrunner/steprunner/tasks/push.py +++ b/buildrunner/steprunner/tasks/push.py @@ -7,10 +7,19 @@ """ import logging import os -from typing import List, Optional +import tempfile +import time +from typing import Dict, List, Optional + +import yaml import buildrunner.docker +from buildrunner.config import BuildRunnerConfig +from buildrunner.config.models import SecurityScanConfig from buildrunner.config.models_step import StepPushCommit +from buildrunner.docker.image_info import BuiltImageInfo +from buildrunner.docker.multiplatform_image_builder import MultiplatformImageBuilder +from buildrunner.docker.runner import DockerRunner from buildrunner.errors import ( BuildRunnerProcessingError, ) @@ -19,6 +28,7 @@ LOGGER = logging.getLogger(__name__) +ARTIFACT_SECURITY_SCAN_KEY = "docker:security-scan" class RepoDefinition: @@ -74,6 +84,229 @@ def __init__(self, step_runner, pushes: List[StepPushCommit], commit_only=False) for push in pushes ] + def _security_scan_mp( + self, built_image: BuiltImageInfo, log_image_ref: str + ) -> Dict[str, dict]: + """ + Does a security scan for each built image in a multiplatform image. + :param built_image: the multiplatform built image info + :param log_image_ref: the image label to log + :return: a dictionary of platform names to security scan information (for each built platform image) + """ + scan_results = {} + for image in built_image.built_images: + result = self._security_scan( + repository=image.repo, + tag=image.tag, + log_image_ref=f"{log_image_ref}:{image.platform}", + pull=True, + ) + if result: + scan_results[image.platform] = result + if not scan_results: + return {} + return {ARTIFACT_SECURITY_SCAN_KEY: scan_results} + + def _security_scan_single(self, repo: str) -> Dict[str, dict]: + tag = BuildRunnerConfig.get_instance().default_tag + log_image_ref = f"{repo}:{tag}" + result = self._security_scan( + repository=repo, tag=tag, log_image_ref=log_image_ref, pull=False + ) + if not result: + return {} + return { + ARTIFACT_SECURITY_SCAN_KEY: { + MultiplatformImageBuilder.get_native_platform(): result + } + } + + def _security_scan( + self, + *, + repository: str, + tag: str, + log_image_ref: str, + pull: bool, + ) -> Optional[dict]: + # If the security scan is not enabled, do nothing + security_scan_config = ( + BuildRunnerConfig.get_instance().global_config.security_scan + ) + if log_image_ref != f"{repository}:{tag}": + log_image_ref = f"{log_image_ref} ({repository}:{tag})" + if not security_scan_config.enabled: + LOGGER.debug( + f"Image scanning is disabled, skipping scan of {log_image_ref}" + ) + return None + + LOGGER.info( + f"Scanning {log_image_ref} for security issues using {security_scan_config.scanner}" + ) + + if security_scan_config.scanner == "trivy": + return self._security_scan_trivy( + security_scan_config=security_scan_config, + repository=repository, + tag=tag, + log_image_ref=log_image_ref, + pull=pull, + ) + raise Exception(f"Unsupported scanner {security_scan_config.scanner}") + + @staticmethod + def _security_scan_trivy_parse_results( + security_scan_config: SecurityScanConfig, results: dict + ) -> dict: + max_score = 0 + vulnerabilities = [] + for result in results.get("Results", []): + if not result.get("Vulnerabilities"): + continue + for cur_vuln in result.get("Vulnerabilities"): + score = cur_vuln.get("CVSS", {}).get("nvd", {}).get("V3Score") + vulnerabilities.append( + { + "cvss_v3_score": score, + "severity": cur_vuln.get("Severity"), + "vulnerability_id": cur_vuln.get("VulnerabilityID"), + "pkg_name": cur_vuln.get("PkgName"), + "installed_version": cur_vuln.get("InstalledVersion"), + "primary_url": cur_vuln.get("PrimaryURL"), + } + ) + if score: + max_score = max(max_score, score) + + if security_scan_config.max_score_threshold: + if max_score >= security_scan_config.max_score_threshold: + raise BuildRunnerProcessingError( + f"Max vulnerability score ({max_score}) is above the " + f"configured threshold ({security_scan_config.max_score_threshold})" + ) + LOGGER.info( + f"Max vulnerability score ({max_score}) is less than the " + f"configured threshold ({security_scan_config.max_score_threshold})" + ) + else: + LOGGER.debug( + f"Max vulnerability score is {max_score}, but no max score threshold is configured" + ) + return { + "max_score": max_score, + "vulnerabilities": vulnerabilities, + } + + def _security_scan_trivy( + self, + *, + security_scan_config: SecurityScanConfig, + repository: str, + tag: str, + log_image_ref: str, + pull: bool, + ) -> dict: + # Pull image for scanning (if not already pulled) so that it can be scanned locally + if pull: + self._docker_client.pull(repository, tag) + + buildrunner_config = BuildRunnerConfig.get_instance() + with tempfile.TemporaryDirectory( + suffix="-trivy-run", + dir=buildrunner_config.global_config.temp_dir, + ) as local_run_dir: + # Set constants for this run + config_file_name = "config.yaml" + results_file_name = "results.json" + container_run_dir = "/trivy" + # Dynamically use the configured cache directory if set, uses the trivy default otherwise + container_cache_dir = security_scan_config.config.get( + "cache-dir", "/root/.cache/trivy" + ) + + # Create local directories for volume mounting (if they don't exist) + local_cache_dir = security_scan_config.cache_dir + if not local_cache_dir: + local_cache_dir = os.path.join( + buildrunner_config.global_config.temp_dir, "trivy-cache" + ) + os.makedirs(local_cache_dir, exist_ok=True) + + # Create run config + with open( + os.path.join(local_run_dir, config_file_name), "w", encoding="utf8" + ) as fobj: + yaml.safe_dump(security_scan_config.config, fobj) + + image_scanner = None + try: + image_config = DockerRunner.ImageConfig( + f"{BuildRunnerConfig.get_instance().global_config.docker_registry}/" + f"aquasec/trivy:{security_scan_config.version}", + pull_image=False, + ) + image_scanner = DockerRunner( + image_config, + log=self.step_runner.log, + ) + image_scanner.start( + entrypoint="/bin/sh", + volumes={ + local_run_dir: container_run_dir, + local_cache_dir: container_cache_dir, + # TODO Implement support for additional connection methods + "/var/run/docker.sock": "/var/run/docker.sock", + }, + ) + + # Print out the trivy version + image_scanner.run("trivy --version", console=self.step_runner.log) + + # Run trivy + start_time = time.time() + exit_code = image_scanner.run( + f"trivy --config {container_run_dir}/{config_file_name} image " + f"-f json -o {container_run_dir}/{results_file_name} {repository}:{tag}", + console=self.step_runner.log, + ) + LOGGER.info( + f"Took {round(time.time() - start_time, 1)} second(s) to scan image" + ) + if exit_code: + raise BuildRunnerProcessingError( + f"Could not scan {log_image_ref} with trivy, see errors above" + ) + + # Load results file and parse the max score + results_file = os.path.join(local_run_dir, results_file_name) + if not os.path.exists(results_file): + raise BuildRunnerProcessingError( + f"Results file {results_file} from trivy for {log_image_ref} does not exist, " + "check for errors above" + ) + with open(results_file, "r", encoding="utf8") as fobj: + results = yaml.safe_load(fobj) + if not results: + raise BuildRunnerProcessingError( + f"Could not read results file {results_file} from trivy for {log_image_ref}, " + "check for errors above" + ) + return self._security_scan_trivy_parse_results( + security_scan_config, results + ) + finally: + if image_scanner: + # make sure the current user/group ids of our + # process are set as the owner of the files + exit_code = image_scanner.run( + f"chown -R {int(os.getuid())}:{int(os.getgid())} {container_run_dir}", + log=self.step_runner.log, + ) + if exit_code != 0: + LOGGER.error("Error running trivy--unable to change ownership") + image_scanner.cleanup() + def run(self, context): # pylint: disable=too-many-branches # Tag multi-platform images built_image = context.get("mp_built_image") @@ -104,6 +337,10 @@ def run(self, context): # pylint: disable=too-many-branches "docker:repository": repo.repository, "docker:tags": repo.tags, "docker:platforms": built_image_id_with_platforms, + **self._security_scan_mp( + built_image, + f"{repo.repository}:{repo.tags[0]}", + ), }, ) @@ -173,6 +410,7 @@ def run(self, context): # pylint: disable=too-many-branches "docker:image": image_to_use, "docker:repository": repo.repository, "docker:tags": repo.tags, + **self._security_scan_single(repo.repository), }, ) diff --git a/buildrunner/steprunner/tasks/run.py b/buildrunner/steprunner/tasks/run.py index d0c424e2..38d21a66 100644 --- a/buildrunner/steprunner/tasks/run.py +++ b/buildrunner/steprunner/tasks/run.py @@ -1020,9 +1020,11 @@ def run(self, context: dict): # pylint: disable=too-many-statements,too-many-br console=container_logger, # log=self.step_runner.log, ) - container_meta_logger.write( - f'Command "{_cmd}" exited with code {exit_code}\n' - ) + if exit_code: + log_method = container_meta_logger.error + else: + log_method = container_meta_logger.info + log_method(f'Command "{_cmd}" exited with code {exit_code}') if exit_code != 0: break diff --git a/docs/global-configuration.rst b/docs/global-configuration.rst index ffdf0bc1..b4cb8982 100644 --- a/docs/global-configuration.rst +++ b/docs/global-configuration.rst @@ -118,6 +118,24 @@ they are used when put into the global configuration file: type: local src: /mnt/docker-cache + security-scan: + # Set to "true" to enable automatic security scans of pushed images + enabled: false + # Only trivy is currently supported + scanner: "trivy" + # The version of the trivy image to pull + version: str = "latest" + # The local cache directory for the scanner (used if supported by the scanner) + cache_dir: null + config: + # Timeout after 20 minutes by default + timeout: 20m + # Do not error on vulnerabilities by default + exit-code: 0 + # Set to a float to fail the build if the maximum score + # is greater than or equal to this number + max-score-threshold: null + Configuration Locations ======================= diff --git a/setup.py b/setup.py index 20a63dac..8bded05f 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ from setuptools import setup, find_packages -BASE_VERSION = "3.3" +BASE_VERSION = "3.4" SOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) BUILDRUNNER_DIR = os.path.join(SOURCE_DIR, "buildrunner") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..6e397b3c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,85 @@ +import argparse +import os + +import pytest +import yaml +from unittest import mock + +from buildrunner import cli + + +class ExitError(BaseException): + pass + + +class MockedArgs(argparse.Namespace): + def __init__(self, args_dict: dict) -> None: + super().__init__(**args_dict) + self.args_dict = args_dict + + def __getattr__(self, item: str): + return self.args_dict.get(item) + + +@pytest.mark.parametrize( + "args, config_file_contents, result", + [ + ({"disable_multi_platform": None}, None, {}), + ({"disable_multi_platform": "false"}, None, {"disable-multi-platform": False}), + ({"disable_multi_platform": "true"}, None, {"disable-multi-platform": True}), + ( + {"security_scan_scanner": "scanner1", "security_scan_version": None}, + None, + {"security-scan": {"scanner": "scanner1"}}, + ), + ( + { + "security_scan_max_score_threshold": 1.1, + }, + {"option1": "val1", "option2": 2}, + { + "security-scan": { + "max-score-threshold": 1.1, + "config": {"option1": "val1", "option2": 2}, + } + }, + ), + ], +) +def test__get_global_config_overrides( + args: dict, config_file_contents, result, tmp_path +): + # Replace the config file with a real file (if specified) + if config_file_contents: + file_path = tmp_path / "file1" + with file_path.open("w", encoding="utf8") as fobj: + yaml.safe_dump(config_file_contents, fobj) + args["security_scan_config_file"] = str(file_path) + assert cli._get_global_config_overrides(MockedArgs(args)) == result + + +@pytest.mark.parametrize( + "file_name, error_message", + [ + ("does-not-exist", "could not be found"), + ("empty-file", "must contain a dictionary"), + ("yaml-list", "must contain a dictionary"), + ("bad-yaml", "could not be loaded: mapping values are not allowed here"), + ], +) +@mock.patch("buildrunner.cli.sys") +def test__load_security_scan_config_file_failure( + sys_mock, file_name, error_message, tmp_path +): + sys_mock.exit.side_effect = ExitError("exit") + + (tmp_path / "empty-file").touch() + (tmp_path / "bad-yaml").write_text("this is totally bogus\nyaml: bad: here") + (tmp_path / "yaml-list").write_text("[]") + + with pytest.raises(ExitError) as exc_info: + cli._load_security_scan_config_file(str(tmp_path / file_name)) + assert str(exc_info.value) == "exit" + sys_mock.exit.assert_called_once_with(os.EX_CONFIG) + sys_mock.stderr.write.assert_called_once() + assert error_message in sys_mock.stderr.write.call_args.args[0] diff --git a/tests/test_config_validation/test_global_config.py b/tests/test_config_validation/test_global_config.py index 4df0c3a0..8ad88923 100644 --- a/tests/test_config_validation/test_global_config.py +++ b/tests/test_config_validation/test_global_config.py @@ -122,6 +122,7 @@ def test_local_files_merged(override_master_config_file, tmp_path): config = loader.load_global_config_files( build_time=123, global_config_files=[str(override_master_config_file), str(file2)], + global_config_overrides={}, ) assert "local-files" in config assert config.get("local-files") == { @@ -130,3 +131,42 @@ def test_local_files_merged(override_master_config_file, tmp_path): "key3": file_path1, "key4": file_path3, } + + +def test_overrides(override_master_config_file, tmp_path): + override_master_config_file.write_text( + """ + security-scan: + scanner: scan1 + config: + k1: v1 + k2: v2.1 + """ + ) + file2 = tmp_path / "file2" + file2.write_text( + """ + security-scan: + config: + k2: v2.2 + k3: v3.1 + """ + ) + config = loader.load_global_config_files( + build_time=123, + global_config_files=[str(override_master_config_file), str(file2)], + global_config_overrides={ + "security-scan": {"version": "1.2.3", "config": {"k3": "v3.2", "k4": "v4"}} + }, + ) + assert "security-scan" in config + assert config.get("security-scan") == { + "scanner": "scan1", + "version": "1.2.3", + "config": { + "k1": "v1", + "k2": "v2.2", + "k3": "v3.2", + "k4": "v4", + }, + } diff --git a/tests/test_config_validation/test_retagging.py b/tests/test_config_validation/test_retagging.py index f18fee25..423a458e 100644 --- a/tests/test_config_validation/test_retagging.py +++ b/tests/test_config_validation/test_retagging.py @@ -330,7 +330,7 @@ def test_valid_config_with_buildrunner_build_tag( docker_timeout=30, local_images=False, platform=None, - disable_multi_platform=False, + global_config_overrides={}, ) buildrunner_config = BuildRunnerConfig.get_instance() push_info = buildrunner_config.run_config.steps["build-container"].push @@ -415,7 +415,7 @@ def test_invalid_retagging_with_buildrunner_build_tag( docker_timeout=30, local_images=False, platform=None, - disable_multi_platform=False, + global_config_overrides={}, ) assert RETAG_ERROR_MESSAGE in excinfo.value.args[0] assert ( diff --git a/tests/test_loggers.py b/tests/test_loggers.py index 44143ac6..d6d80316 100644 --- a/tests/test_loggers.py +++ b/tests/test_loggers.py @@ -25,7 +25,7 @@ def fixture_override_colors(): @pytest.mark.parametrize( - "debug, no_color, disable_timestamps, log_level, fmt", + "debug, no_color, disable_timestamps, log_level, console_format, file_format", [ ( False, @@ -33,13 +33,28 @@ def fixture_override_colors(): False, logging.INFO, "%(log_color)s%(asctime)s %(levelname)-8s %(message)s", + "%(log_color)s%(asctime)s %(levelname)-8s %(message)s", + ), + ( + True, + True, + True, + logging.DEBUG, + "%(log_color)s%(levelname)-8s %(message)s", + "%(log_color)s%(asctime)s %(levelname)-8s %(message)s", ), - (True, True, True, logging.DEBUG, "%(log_color)s%(levelname)-8s %(message)s"), ], ) @mock.patch("buildrunner.loggers.logging") def test_initialize_root_logger( - logging_mock, debug, no_color, disable_timestamps, log_level, fmt, tmp_path + logging_mock, + debug, + no_color, + disable_timestamps, + log_level, + console_format, + file_format, + tmp_path, ): logging_mock.DEBUG = logging.DEBUG logging_mock.INFO = logging.INFO @@ -71,11 +86,11 @@ def test_initialize_root_logger( # Check formatters file_formatter = file_handler.setFormatter.call_args.args[0] - assert file_formatter.fmt == fmt + assert file_formatter.fmt == file_format assert file_formatter.no_color assert file_formatter.color == "white" stream_formatter = stream_handler.setFormatter.call_args.args[0] - assert stream_formatter.fmt == fmt + assert stream_formatter.fmt == console_format assert stream_formatter.no_color == no_color assert stream_formatter.color == "white" # Make sure the formatters are not the same, they should be distinct diff --git a/tests/test_push_security_scan.py b/tests/test_push_security_scan.py new file mode 100644 index 00000000..d362b783 --- /dev/null +++ b/tests/test_push_security_scan.py @@ -0,0 +1,447 @@ +import os +from unittest import mock + +import pytest +import yaml + +from buildrunner.config.models import SecurityScanConfig +from buildrunner.errors import BuildRunnerProcessingError +from buildrunner.steprunner.tasks import push + + +@pytest.fixture(name="config_mock") +def fixture_config_mock(): + with mock.patch( + "buildrunner.steprunner.tasks.push.BuildRunnerConfig" + ) as buildrunner_config_mock: + config_mock = mock.MagicMock() + buildrunner_config_mock.get_instance.return_value = config_mock + yield config_mock + + +def _generate_built_image(num: int) -> mock.MagicMock(): + image = mock.MagicMock() + image.repo = f"repo{num}" + image.tag = f"tag{num}" + image.platform = f"platform{num}" + return image + + +def test__security_scan_mp(): + image_info = mock.MagicMock() + image_info.built_images = [_generate_built_image(num) for num in range(1, 4)] + + self_mock = mock.MagicMock() + self_mock._security_scan.side_effect = ( + lambda **kwargs: None + if kwargs["repository"] == "repo2" + else {"image": kwargs["repository"]} + ) + + assert push.PushBuildStepRunnerTask._security_scan_mp( + self_mock, image_info, "image_ref1" + ) == { + "docker:security-scan": { + "platform1": {"image": "repo1"}, + "platform3": {"image": "repo3"}, + } + } + assert self_mock._security_scan.call_args_list == [ + mock.call( + repository="repo1", + tag="tag1", + log_image_ref="image_ref1:platform1", + pull=True, + ), + mock.call( + repository="repo2", + tag="tag2", + log_image_ref="image_ref1:platform2", + pull=True, + ), + mock.call( + repository="repo3", + tag="tag3", + log_image_ref="image_ref1:platform3", + pull=True, + ), + ] + + +def test__security_scan_mp_empty(): + image_info = mock.MagicMock() + image_info.built_images = [_generate_built_image(num) for num in range(1, 4)] + + self_mock = mock.MagicMock() + self_mock._security_scan.return_value = None + + assert not push.PushBuildStepRunnerTask._security_scan_mp( + self_mock, image_info, "image_ref1" + ) + + +@mock.patch( + "buildrunner.steprunner.tasks.push.MultiplatformImageBuilder.get_native_platform" +) +def test__security_scan_single(get_native_platform_mock, config_mock): + get_native_platform_mock.return_value = "platform1" + config_mock.default_tag = "abc123" + self_mock = mock.MagicMock() + self_mock._security_scan.return_value = {"result": True} + assert push.PushBuildStepRunnerTask._security_scan_single(self_mock, "repo1") == { + "docker:security-scan": {"platform1": {"result": True}} + } + self_mock._security_scan.assert_called_once_with( + repository="repo1", tag="abc123", log_image_ref="repo1:abc123", pull=False + ) + + +@mock.patch( + "buildrunner.steprunner.tasks.push.MultiplatformImageBuilder.get_native_platform" +) +def test__security_scan_single_empty(get_native_platform_mock, config_mock): + get_native_platform_mock.return_value = "platform1" + config_mock.default_tag = "abc123" + self_mock = mock.MagicMock() + self_mock._security_scan.return_value = None + assert push.PushBuildStepRunnerTask._security_scan_single(self_mock, "repo1") == {} + self_mock._security_scan.assert_called_once() + + +def test__security_scan_scanner_disabled(config_mock): + config_mock.global_config.security_scan = SecurityScanConfig(enabled=False) + self_mock = mock.MagicMock() + assert not push.PushBuildStepRunnerTask._security_scan( + self_mock, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=False, + ) + self_mock._security_scan_trivy.assert_not_called() + + +def test__security_scan_scanner_trivy(config_mock): + config_mock.global_config.security_scan = SecurityScanConfig( + enabled=True, scanner="trivy" + ) + self_mock = mock.MagicMock() + self_mock._security_scan_trivy.return_value = {"result": True} + assert push.PushBuildStepRunnerTask._security_scan( + self_mock, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=False, + ) == {"result": True} + self_mock._security_scan_trivy.assert_called_once_with( + security_scan_config=config_mock.global_config.security_scan, + repository="repo1", + tag="tag1", + log_image_ref="image1 (repo1:tag1)", + pull=False, + ) + + +def test__security_scan_scanner_unsupported(config_mock): + config_mock.global_config.security_scan = SecurityScanConfig( + enabled=True, scanner="bogus" + ) + self_mock = mock.MagicMock() + with pytest.raises(Exception) as exc_info: + assert push.PushBuildStepRunnerTask._security_scan( + self_mock, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=False, + ) == {"result": True} + assert "Unsupported scanner" in str(exc_info.value) + self_mock._security_scan_trivy.assert_not_called() + + +@pytest.mark.parametrize( + "input_results, parsed_results", + [ + ({}, {"max_score": 0, "vulnerabilities": []}), + ({"Results": []}, {"max_score": 0, "vulnerabilities": []}), + ( + {"Results": [{"Vulnerabilities": [{}]}]}, + { + "max_score": 0, + "vulnerabilities": [ + { + "cvss_v3_score": None, + "severity": None, + "vulnerability_id": None, + "pkg_name": None, + "installed_version": None, + "primary_url": None, + } + ], + }, + ), + ( + { + "Results": [ + { + "Vulnerabilities": [ + { + "CVSS": {"nvd": {"V3Score": 1.0}}, + "Severity": "HIGH", + "VulnerabilityID": "CVE1", + "PkgName": "pkg1", + "InstalledVersion": "v1", + "PrimaryURL": "url1", + } + ] + } + ] + }, + { + "max_score": 1.0, + "vulnerabilities": [ + { + "cvss_v3_score": 1.0, + "severity": "HIGH", + "vulnerability_id": "CVE1", + "pkg_name": "pkg1", + "installed_version": "v1", + "primary_url": "url1", + } + ], + }, + ), + ], +) +def test__security_scan_trivy_parse_results(input_results, parsed_results): + security_scan_config = SecurityScanConfig() + assert ( + push.PushBuildStepRunnerTask._security_scan_trivy_parse_results( + security_scan_config, input_results + ) + == parsed_results + ) + + +@pytest.mark.parametrize( + "max_score_threshold, exception_raised", + [ + (None, False), + (2.11, False), + (2.1, True), + ], +) +def test__security_scan_trivy_parse_results_max_score_threshold( + max_score_threshold, exception_raised +): + security_scan_config = SecurityScanConfig( + **{"max-score-threshold": max_score_threshold} + ) + input_results = { + "Results": [ + { + "Vulnerabilities": [ + { + "CVSS": {"nvd": {"V3Score": 1.0}}, + }, + { + "CVSS": {"nvd": {"V3Score": None}}, + }, + { + "CVSS": {"nvd": {"V3Score": 0}}, + }, + ], + }, + { + "Vulnerabilities": [ + { + "CVSS": {"nvd": {"V3Score": 2.1}}, + }, + { + "CVSS": {"nvd": {"V3Score": 1.9}}, + }, + ], + }, + ] + } + if exception_raised: + with pytest.raises( + BuildRunnerProcessingError, match="is above the configured threshold" + ): + push.PushBuildStepRunnerTask._security_scan_trivy_parse_results( + security_scan_config, + input_results, + ) + else: + push.PushBuildStepRunnerTask._security_scan_trivy_parse_results( + security_scan_config, input_results + ) + + +@mock.patch("buildrunner.steprunner.tasks.push.DockerRunner") +@mock.patch("buildrunner.steprunner.tasks.push.tempfile") +@mock.patch("buildrunner.steprunner.tasks.push.os") +def test__security_scan_trivy( + os_mock, tempfile_mock, docker_runner_mock, config_mock, tmp_path +): + os_mock.makedirs = os.makedirs + os_mock.path = os.path + os_mock.getuid.return_value = "123" + os_mock.getgid.return_value = "234" + + run_path = tmp_path / "run" + run_path.mkdir() + tempfile_mock.TemporaryDirectory.return_value.__enter__.return_value = str(run_path) + + config_mock.global_config.docker_registry = "registry1" + security_scan_config = SecurityScanConfig() + self_mock = mock.MagicMock() + self_mock._security_scan_trivy_parse_results.return_value = {"parsed_results": True} + config_mock.global_config.temp_dir = str(tmp_path) + + def _call_run(command, **kwargs): + _ = kwargs + if command.startswith("trivy --config"): + (run_path / "results.json").write_text('{"results": True}') + return 0 + + docker_runner_mock.return_value.run.side_effect = _call_run + + assert push.PushBuildStepRunnerTask._security_scan_trivy( + self_mock, + security_scan_config=security_scan_config, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=True, + ) == {"parsed_results": True} + + assert set(path.name for path in tmp_path.iterdir()) == { + "run", + "trivy-cache", + } + assert set(path.name for path in run_path.iterdir()) == { + "config.yaml", + "results.json", + } + assert ( + yaml.safe_load((run_path / "config.yaml").read_text()) + == security_scan_config.config + ) + assert ( + yaml.safe_load((run_path / "config.yaml").read_text()) + == security_scan_config.config + ) + + docker_runner_mock.ImageConfig.assert_called_once_with( + "registry1/aquasec/trivy:latest", + pull_image=False, + ) + docker_runner_mock.assert_called_once_with( + docker_runner_mock.ImageConfig.return_value, + log=self_mock.step_runner.log, + ) + docker_runner_mock().start.assert_called_once_with( + entrypoint="/bin/sh", + volumes={ + str(run_path): "/trivy", + str(tmp_path / "trivy-cache"): "/root/.cache/trivy", + "/var/run/docker.sock": "/var/run/docker.sock", + }, + ) + assert docker_runner_mock().run.call_args_list == [ + mock.call("trivy --version", console=self_mock.step_runner.log), + mock.call( + "trivy --config /trivy/config.yaml image -f json -o /trivy/results.json repo1:tag1", + console=self_mock.step_runner.log, + ), + mock.call( + "chown -R 123:234 /trivy", + log=self_mock.step_runner.log, + ), + ] + docker_runner_mock().cleanup.assert_called_once_with() + self_mock._security_scan_trivy_parse_results.assert_called_once_with( + security_scan_config, {"results": True} + ) + + +@mock.patch("buildrunner.steprunner.tasks.push.DockerRunner") +def test__security_scan_trivy_failure(docker_runner_mock, config_mock, tmp_path): + config_mock.global_config.docker_registry = "registry1" + security_scan_config = SecurityScanConfig() + self_mock = mock.MagicMock() + config_mock.global_config.temp_dir = str(tmp_path) + docker_runner_mock.return_value.run.return_value = 1 + + with pytest.raises(BuildRunnerProcessingError, match="Could not scan"): + push.PushBuildStepRunnerTask._security_scan_trivy( + self_mock, + security_scan_config=security_scan_config, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=True, + ) + + docker_runner_mock.ImageConfig.assert_called_once() + docker_runner_mock.assert_called_once() + docker_runner_mock().start.assert_called_once() + assert docker_runner_mock().run.call_count == 3 + docker_runner_mock().cleanup.assert_called_once() + self_mock._security_scan_trivy_parse_results.assert_not_called() + + +@mock.patch("buildrunner.steprunner.tasks.push.DockerRunner") +def test__security_scan_trivy_file_not_created( + docker_runner_mock, config_mock, tmp_path +): + config_mock.global_config.docker_registry = "registry1" + security_scan_config = SecurityScanConfig() + self_mock = mock.MagicMock() + config_mock.global_config.temp_dir = str(tmp_path) + docker_runner_mock.return_value.run.return_value = 0 + + with pytest.raises(BuildRunnerProcessingError, match="does not exist"): + push.PushBuildStepRunnerTask._security_scan_trivy( + self_mock, + security_scan_config=security_scan_config, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=True, + ) + + +@mock.patch("buildrunner.steprunner.tasks.push.DockerRunner") +@mock.patch("buildrunner.steprunner.tasks.push.tempfile") +def test__security_scan_trivy_empty_file( + tempfile_mock, docker_runner_mock, config_mock, tmp_path +): + run_path = tmp_path / "run" + run_path.mkdir() + tempfile_mock.TemporaryDirectory.return_value.__enter__.return_value = str(run_path) + + config_mock.global_config.docker_registry = "registry1" + security_scan_config = SecurityScanConfig() + self_mock = mock.MagicMock() + config_mock.global_config.temp_dir = str(tmp_path) + + def _call_run(command, **kwargs): + _ = kwargs + if command.startswith("trivy --config"): + (run_path / "results.json").write_text("{}") + return 0 + + docker_runner_mock.return_value.run.side_effect = _call_run + + with pytest.raises(BuildRunnerProcessingError, match="Could not read results file"): + push.PushBuildStepRunnerTask._security_scan_trivy( + self_mock, + security_scan_config=security_scan_config, + repository="repo1", + tag="tag1", + log_image_ref="image1", + pull=True, + )