diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 0f96bbfd6c..12558a3f70 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778) - CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779) - CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777) +- Container Image provider (POC) using Trivy for vulnerability and secret scanning ### Changed - Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index a4d345f6c8..6ae4ca059e 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -804,7 +804,10 @@ class CheckReportImage(Check_Report): fixed_version: str def __init__( - self, metadata: dict = {}, finding: dict = {}, image_name: str = "" + self, + metadata: Optional[dict] = None, + finding: Optional[dict] = None, + image_name: str = "", ) -> None: """ Initialize the Container Image Check's finding information from a Trivy vulnerability/secret dict. @@ -814,6 +817,10 @@ class CheckReportImage(Check_Report): finding (dict): A single vulnerability/secret result from Trivy's JSON output. image_name (str): The container image name being scanned. """ + if metadata is None: + metadata = {} + if finding is None: + finding = {} super().__init__(metadata, finding) self.resource = finding diff --git a/prowler/providers/image/exceptions/__init__.py b/prowler/providers/image/exceptions/__init__.py new file mode 100644 index 0000000000..95b0b3a110 --- /dev/null +++ b/prowler/providers/image/exceptions/__init__.py @@ -0,0 +1,19 @@ +from prowler.providers.image.exceptions.exceptions import ( + ImageBaseException, + ImageFindingProcessingError, + ImageListFileNotFoundError, + ImageListFileReadError, + ImageNoImagesProvidedError, + ImageScanError, + ImageTrivyBinaryNotFoundError, +) + +__all__ = [ + "ImageBaseException", + "ImageFindingProcessingError", + "ImageListFileNotFoundError", + "ImageListFileReadError", + "ImageNoImagesProvidedError", + "ImageScanError", + "ImageTrivyBinaryNotFoundError", +] diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py new file mode 100644 index 0000000000..f819462883 --- /dev/null +++ b/prowler/providers/image/exceptions/exceptions.py @@ -0,0 +1,99 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 9000 to 9999 are reserved for Image exceptions +class ImageBaseException(ProwlerException): + """Base class for Image provider errors.""" + + IMAGE_ERROR_CODES = { + (9000, "ImageNoImagesProvidedError"): { + "message": "No container images provided for scanning.", + "remediation": "Provide at least one image using --image or --image-list-file.", + }, + (9001, "ImageListFileNotFoundError"): { + "message": "Image list file not found.", + "remediation": "Ensure the image list file exists at the specified path.", + }, + (9002, "ImageListFileReadError"): { + "message": "Error reading image list file.", + "remediation": "Check file permissions and format. The file should contain one image per line.", + }, + (9003, "ImageFindingProcessingError"): { + "message": "Error processing image scan finding.", + "remediation": "Check the Trivy output format and ensure the finding structure is valid.", + }, + (9004, "ImageTrivyBinaryNotFoundError"): { + "message": "Trivy binary not found.", + "remediation": "Install Trivy from https://trivy.dev/latest/getting-started/installation/", + }, + (9005, "ImageScanError"): { + "message": "Error scanning container image.", + "remediation": "Check the image name and ensure it is accessible.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + error_info = self.IMAGE_ERROR_CODES.get((code, self.__class__.__name__)) + if message: + error_info["message"] = message + super().__init__( + code, + source="Image", + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class ImageNoImagesProvidedError(ImageBaseException): + """Exception raised when no container images are provided for scanning.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9000, file=file, original_exception=original_exception, message=message + ) + + +class ImageListFileNotFoundError(ImageBaseException): + """Exception raised when the image list file is not found.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9001, file=file, original_exception=original_exception, message=message + ) + + +class ImageListFileReadError(ImageBaseException): + """Exception raised when the image list file cannot be read.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9002, file=file, original_exception=original_exception, message=message + ) + + +class ImageFindingProcessingError(ImageBaseException): + """Exception raised when a finding cannot be processed.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9003, file=file, original_exception=original_exception, message=message + ) + + +class ImageTrivyBinaryNotFoundError(ImageBaseException): + """Exception raised when the Trivy binary is not found.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9004, file=file, original_exception=original_exception, message=message + ) + + +class ImageScanError(ImageBaseException): + """Exception raised when a general scan error occurs.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9005, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 92cdaa1a03..24f23dc02d 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import subprocess import sys @@ -15,6 +17,13 @@ from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider +from prowler.providers.image.exceptions.exceptions import ( + ImageFindingProcessingError, + ImageListFileNotFoundError, + ImageListFileReadError, + ImageNoImagesProvidedError, + ImageTrivyBinaryNotFoundError, +) class ImageProvider(Provider): @@ -30,22 +39,22 @@ class ImageProvider(Provider): def __init__( self, - images: list[str] = [], + images: list[str] | None = None, image_list_file: str = None, - scanners: list[str] = ["vuln", "secret"], - trivy_severity: list[str] = [], + scanners: list[str] | None = None, + trivy_severity: list[str] | None = None, ignore_unfixed: bool = False, timeout: str = "5m", config_path: str = None, config_content: dict = None, - fixer_config: dict = {}, + fixer_config: dict | None = None, ): logger.info("Instantiating Image Provider...") - self.images = images or [] + self.images = images if images is not None else [] self.image_list_file = image_list_file - self.scanners = scanners - self.trivy_severity = trivy_severity + self.scanners = scanners if scanners is not None else ["vuln", "secret"] + self.trivy_severity = trivy_severity if trivy_severity is not None else [] self.ignore_unfixed = ignore_unfixed self.timeout = timeout self.region = "container" @@ -59,8 +68,10 @@ class ImageProvider(Provider): self._load_images_from_file(image_list_file) if not self.images: - logger.error("No images provided for scanning") - sys.exit(1) + raise ImageNoImagesProvidedError( + file=__file__, + message="No images provided for scanning.", + ) # Audit Config if config_content: @@ -71,7 +82,7 @@ class ImageProvider(Provider): self._audit_config = load_and_validate_config_file(self._type, config_path) # Fixer Config - self._fixer_config = fixer_config + self._fixer_config = fixer_config if fixer_config is not None else {} # Mutelist (not needed for Image provider since Trivy has its own logic) self._mutelist = None @@ -99,37 +110,42 @@ class ImageProvider(Provider): self.images.append(line) logger.info(f"Loaded {len(self.images)} images from {file_path}") except FileNotFoundError: - logger.error(f"Image list file not found: {file_path}") - sys.exit(1) + raise ImageListFileNotFoundError( + file=file_path, + message=f"Image list file not found: {file_path}", + ) except Exception as error: - logger.error(f"Error reading image list file: {error}") - sys.exit(1) + raise ImageListFileReadError( + file=file_path, + original_exception=error, + message=f"Error reading image list file: {error}", + ) @property - def auth_method(self): + def auth_method(self) -> str: return self._auth_method @property - def type(self): + def type(self) -> str: return self._type @property - def identity(self): + def identity(self) -> str: return self._identity @property - def session(self): + def session(self) -> None: return self._session @property - def audit_config(self): + def audit_config(self) -> dict: return self._audit_config @property - def fixer_config(self): + def fixer_config(self) -> dict: return self._fixer_config - def setup_session(self): + def setup_session(self) -> None: """Image provider doesn't need a session since it uses Trivy directly""" return None @@ -187,6 +203,7 @@ class ImageProvider(Provider): "ResourceIdTemplate": "", "Severity": trivy_severity, "ResourceType": "container-image", + "ResourceGroup": "container", "Description": finding_description, "Risk": finding.get( "Description", "Vulnerability detected in container image" @@ -222,10 +239,11 @@ class ImageProvider(Provider): return report except Exception as error: - logger.critical( - f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + raise ImageFindingProcessingError( + file=__file__, + original_exception=error, + message=f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}", ) - sys.exit(1) def _build_status_extended(self, finding: dict) -> str: """Build a detailed status message for the finding.""" @@ -369,11 +387,11 @@ class ImageProvider(Provider): except Exception as error: if "No such file or directory: 'trivy'" in str(error): - logger.critical( - "Trivy binary not found. Please install Trivy from " - "https://trivy.dev/latest/getting-started/installation/" + raise ImageTrivyBinaryNotFoundError( + file=__file__, + original_exception=error, + message="Trivy binary not found. Please install Trivy from https://trivy.dev/latest/getting-started/installation/", ) - sys.exit(1) logger.error(f"Error scanning image {image}: {error}") def _execute_trivy(self, command: list, image: str) -> subprocess.CompletedProcess: @@ -429,7 +447,7 @@ class ImageProvider(Provider): else: logger.info(line) - def print_credentials(self): + def print_credentials(self) -> None: """Print scan configuration.""" report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}" diff --git a/tests/providers/image/image_fixtures.py b/tests/providers/image/image_fixtures.py new file mode 100644 index 0000000000..a51f934bda --- /dev/null +++ b/tests/providers/image/image_fixtures.py @@ -0,0 +1,92 @@ +import json + +# Sample vulnerability finding from Trivy +SAMPLE_VULNERABILITY_FINDING = { + "VulnerabilityID": "CVE-2024-1234", + "PkgID": "openssl@1.1.1k-r0", + "PkgName": "openssl", + "InstalledVersion": "1.1.1k-r0", + "FixedVersion": "1.1.1l-r0", + "Severity": "HIGH", + "Title": "OpenSSL Buffer Overflow", + "Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234", +} + +# Sample secret finding from Trivy +SAMPLE_SECRET_FINDING = { + "RuleID": "aws-access-key-id", + "Category": "AWS", + "Severity": "CRITICAL", + "Title": "AWS Access Key ID", + "StartLine": 10, + "EndLine": 10, + "Match": "AKIA...", +} + +# Sample misconfiguration finding from Trivy +SAMPLE_MISCONFIGURATION_FINDING = { + "ID": "DS001", + "Title": "Dockerfile should not use latest tag", + "Description": "Using latest tag can cause unpredictable builds.", + "Severity": "MEDIUM", + "Resolution": "Use a specific version tag instead of latest", + "PrimaryURL": "https://avd.aquasec.com/misconfig/ds001", +} + +# Sample finding with UNKNOWN severity +SAMPLE_UNKNOWN_SEVERITY_FINDING = { + "VulnerabilityID": "CVE-2024-9999", + "PkgID": "test-pkg@0.0.1", + "PkgName": "test-pkg", + "InstalledVersion": "0.0.1", + "Severity": "UNKNOWN", + "Title": "Unknown severity issue", + "Description": "An issue with unknown severity.", +} + +# Full Trivy JSON output structure with a single vulnerability +SAMPLE_TRIVY_IMAGE_OUTPUT = { + "Results": [ + { + "Target": "alpine:3.18 (alpine 3.18.0)", + "Type": "alpine", + "Vulnerabilities": [SAMPLE_VULNERABILITY_FINDING], + "Secrets": [], + "Misconfigurations": [], + } + ] +} + +# Full Trivy JSON output with mixed finding types +SAMPLE_TRIVY_MULTI_TYPE_OUTPUT = { + "Results": [ + { + "Target": "myimage:latest (debian 12)", + "Type": "debian", + "Vulnerabilities": [SAMPLE_VULNERABILITY_FINDING], + "Secrets": [SAMPLE_SECRET_FINDING], + "Misconfigurations": [SAMPLE_MISCONFIGURATION_FINDING], + } + ] +} + + +def get_sample_trivy_json_output(): + """Return sample Trivy JSON output as string.""" + return json.dumps(SAMPLE_TRIVY_IMAGE_OUTPUT) + + +def get_empty_trivy_output(): + """Return empty Trivy output as string.""" + return json.dumps({"Results": []}) + + +def get_invalid_trivy_output(): + """Return invalid JSON output as string.""" + return "invalid json output" + + +def get_multi_type_trivy_output(): + """Return Trivy output with multiple finding types as string.""" + return json.dumps(SAMPLE_TRIVY_MULTI_TYPE_OUTPUT) diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py new file mode 100644 index 0000000000..1ee91f5c90 --- /dev/null +++ b/tests/providers/image/image_provider_test.py @@ -0,0 +1,354 @@ +import tempfile +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.lib.check.models import CheckReportImage +from prowler.providers.image.exceptions.exceptions import ( + ImageListFileNotFoundError, + ImageNoImagesProvidedError, + ImageTrivyBinaryNotFoundError, +) +from prowler.providers.image.image_provider import ImageProvider +from tests.providers.image.image_fixtures import ( + SAMPLE_MISCONFIGURATION_FINDING, + SAMPLE_SECRET_FINDING, + SAMPLE_UNKNOWN_SEVERITY_FINDING, + SAMPLE_VULNERABILITY_FINDING, + get_empty_trivy_output, + get_invalid_trivy_output, + get_multi_type_trivy_output, + get_sample_trivy_json_output, +) + + +def _make_provider(**kwargs): + """Helper to create an ImageProvider with test defaults.""" + defaults = { + "images": ["alpine:3.18"], + "config_content": {}, + } + defaults.update(kwargs) + return ImageProvider(**defaults) + + +class TestImageProvider: + def test_image_provider(self): + """Test default initialization.""" + provider = _make_provider() + + assert provider._type == "image" + assert provider.type == "image" + assert provider.images == ["alpine:3.18"] + assert provider.scanners == ["vuln", "secret"] + assert provider.trivy_severity == [] + assert provider.ignore_unfixed is False + assert provider.timeout == "5m" + assert provider.region == "container" + assert provider.audited_account == "image-scan" + assert provider.identity == "prowler" + assert provider.auth_method == "No auth" + assert provider.session is None + assert provider.audit_config == {} + assert provider.fixer_config == {} + assert provider._mutelist is None + + def test_image_provider_custom_params(self): + """Test initialization with custom parameters.""" + provider = _make_provider( + images=["nginx:1.25", "redis:7"], + scanners=["vuln", "secret", "misconfig"], + trivy_severity=["HIGH", "CRITICAL"], + ignore_unfixed=True, + timeout="10m", + fixer_config={"key": "value"}, + ) + + assert provider.images == ["nginx:1.25", "redis:7"] + assert provider.scanners == ["vuln", "secret", "misconfig"] + assert provider.trivy_severity == ["HIGH", "CRITICAL"] + assert provider.ignore_unfixed is True + assert provider.timeout == "10m" + assert provider.fixer_config == {"key": "value"} + + def test_image_provider_with_image_list_file(self): + """Test loading images from a file, skipping comments and blank lines.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("# Comment line\n") + f.write("alpine:3.18\n") + f.write("\n") + f.write(" nginx:latest \n") + f.write("# Another comment\n") + f.write("redis:7\n") + f.name + + provider = _make_provider( + images=None, + image_list_file=f.name, + ) + + assert "alpine:3.18" in provider.images + assert "nginx:latest" in provider.images + assert "redis:7" in provider.images + assert len(provider.images) == 3 + + def test_image_provider_no_images(self): + """Test that ImageNoImagesProvidedError is raised when no images are given.""" + with pytest.raises(ImageNoImagesProvidedError): + _make_provider(images=[]) + + def test_image_provider_image_list_file_not_found(self): + """Test that ImageListFileNotFoundError is raised for missing file.""" + with pytest.raises(ImageListFileNotFoundError): + _make_provider( + images=None, + image_list_file="/nonexistent/path/images.txt", + ) + + def test_process_finding_vulnerability(self): + """Test processing a vulnerability finding.""" + provider = _make_provider() + report = provider._process_finding( + SAMPLE_VULNERABILITY_FINDING, + "alpine:3.18 (alpine 3.18.0)", + "alpine", + ) + + assert isinstance(report, CheckReportImage) + assert report.status == "FAIL" + assert report.check_metadata.CheckID == "CVE-2024-1234" + assert report.check_metadata.Severity == "high" + assert report.check_metadata.ServiceName == "alpine" + assert report.check_metadata.ResourceType == "container-image" + assert report.check_metadata.ResourceGroup == "container" + assert report.package_name == "openssl" + assert report.installed_version == "1.1.1k-r0" + assert report.fixed_version == "1.1.1l-r0" + assert report.resource_name == "alpine:3.18 (alpine 3.18.0)" + assert report.region == "container" + + def test_process_finding_secret(self): + """Test processing a secret finding (identified by RuleID).""" + provider = _make_provider() + report = provider._process_finding( + SAMPLE_SECRET_FINDING, + "myimage:latest", + "secret", + ) + + assert isinstance(report, CheckReportImage) + assert report.status == "FAIL" + assert report.check_metadata.CheckID == "aws-access-key-id" + assert report.check_metadata.Severity == "critical" + assert report.check_metadata.ServiceName == "secret" + + def test_process_finding_misconfiguration(self): + """Test processing a misconfiguration finding (identified by ID).""" + provider = _make_provider() + report = provider._process_finding( + SAMPLE_MISCONFIGURATION_FINDING, + "myimage:latest", + "misconfiguration", + ) + + assert isinstance(report, CheckReportImage) + assert report.check_metadata.CheckID == "DS001" + assert report.check_metadata.Severity == "medium" + assert report.check_metadata.ServiceName == "misconfiguration" + + def test_process_finding_unknown_severity(self): + """Test that UNKNOWN severity is mapped to informational.""" + provider = _make_provider() + report = provider._process_finding( + SAMPLE_UNKNOWN_SEVERITY_FINDING, + "myimage:latest", + "alpine", + ) + + assert report.check_metadata.Severity == "informational" + + @patch("subprocess.run") + def test_run_scan_success(self, mock_subprocess): + """Test successful scan with mocked subprocess.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + stdout=get_sample_trivy_json_output(), stderr="" + ) + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert len(reports) == 1 + assert reports[0].check_metadata.CheckID == "CVE-2024-1234" + + @patch("subprocess.run") + def test_run_scan_empty_output(self, mock_subprocess): + """Test scan with empty Trivy output produces no findings.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + stdout=get_empty_trivy_output(), stderr="" + ) + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert len(reports) == 0 + + @patch("subprocess.run") + def test_run_scan_invalid_json(self, mock_subprocess): + """Test scan with malformed output doesn't crash.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + stdout=get_invalid_trivy_output(), stderr="" + ) + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert len(reports) == 0 + + @patch("subprocess.run") + def test_run_scan_trivy_not_found(self, mock_subprocess): + """Test that ImageTrivyBinaryNotFoundError is raised when trivy is missing.""" + provider = _make_provider() + mock_subprocess.side_effect = FileNotFoundError( + "[Errno 2] No such file or directory: 'trivy'" + ) + + with pytest.raises(ImageTrivyBinaryNotFoundError): + for _ in provider._scan_single_image("alpine:3.18"): + pass + + @patch("subprocess.run") + def test_run_scan_multiple_images(self, mock_subprocess): + """Test scanning multiple images makes separate subprocess calls.""" + provider = _make_provider(images=["alpine:3.18", "nginx:latest"]) + mock_subprocess.return_value = MagicMock( + stdout=get_sample_trivy_json_output(), stderr="" + ) + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert mock_subprocess.call_count == 2 + + @patch("subprocess.run") + def test_run_scan_multi_type_output(self, mock_subprocess): + """Test scan with vulnerabilities, secrets, and misconfigurations.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + stdout=get_multi_type_trivy_output(), stderr="" + ) + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert len(reports) == 3 + + check_ids = [r.check_metadata.CheckID for r in reports] + assert "CVE-2024-1234" in check_ids + assert "aws-access-key-id" in check_ids + assert "DS001" in check_ids + + def test_print_credentials(self): + """Test that print_credentials outputs image names.""" + provider = _make_provider() + with mock.patch("builtins.print") as mock_print: + provider.print_credentials() + output = " ".join( + str(call.args[0]) for call in mock_print.call_args_list if call.args + ) + assert "alpine:3.18" in output + + @patch("subprocess.run") + def test_test_connection_success(self, mock_subprocess): + """Test successful connection returns is_connected=True.""" + mock_subprocess.return_value = MagicMock(returncode=0, stderr="") + + result = ImageProvider.test_connection(image="alpine:3.18") + + assert result.is_connected is True + + @patch("subprocess.run") + def test_test_connection_auth_failure(self, mock_subprocess): + """Test 401 error returns auth failure.""" + mock_subprocess.return_value = MagicMock( + returncode=1, stderr="401 unauthorized" + ) + + result = ImageProvider.test_connection(image="private/image:latest") + + assert result.is_connected is False + assert "Authentication failed" in result.error + + @patch("subprocess.run") + def test_test_connection_not_found(self, mock_subprocess): + """Test 404 error returns not found.""" + mock_subprocess.return_value = MagicMock(returncode=1, stderr="404 not found") + + result = ImageProvider.test_connection(image="nonexistent/image:latest") + + assert result.is_connected is False + assert "not found" in result.error + + def test_build_status_extended(self): + """Test status message content for different finding types.""" + provider = _make_provider() + + # Vulnerability with fix + status = provider._build_status_extended(SAMPLE_VULNERABILITY_FINDING) + assert "CVE-2024-1234" in status + assert "openssl" in status + assert "fix available" in status + + # Finding with no special fields + status = provider._build_status_extended({"Description": "Simple finding"}) + assert status == "Simple finding" + + # Finding with will_not_fix status + finding_no_fix = { + "VulnerabilityID": "CVE-2024-0000", + "PkgName": "libc", + "Status": "will_not_fix", + "Title": "Some vuln", + } + status = provider._build_status_extended(finding_no_fix) + assert "no fix available" in status + + def test_validate_arguments(self): + """Test valid and invalid argument combinations.""" + # Valid: images provided + provider = _make_provider(images=["alpine:3.18"]) + assert provider.images == ["alpine:3.18"] + + # Invalid: empty images and no file + with pytest.raises(ImageNoImagesProvidedError): + _make_provider(images=[]) + + # Valid: custom scanners + provider = _make_provider(scanners=["vuln"]) + assert provider.scanners == ["vuln"] + + def test_setup_session(self): + """Test that setup_session returns None.""" + provider = _make_provider() + assert provider.setup_session() is None + + @patch("subprocess.run") + def test_run_method(self, mock_subprocess): + """Test that run() collects all batches into a list.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + stdout=get_sample_trivy_json_output(), stderr="" + ) + + reports = provider.run() + + assert isinstance(reports, list) + assert len(reports) == 1