import os import tempfile from argparse import Namespace from unittest import mock from unittest.mock import MagicMock, patch import pytest from prowler.lib.check.models import CheckReportImage from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( ImageInvalidConfigScannerError, ImageInvalidNameError, ImageInvalidScannerError, ImageInvalidSeverityError, ImageInvalidTimeoutError, ImageListFileNotFoundError, ImageListFileReadError, ImageNoImagesProvidedError, ImageRegistryAuthError, ImageScanError, ImageTrivyBinaryNotFoundError, ) from prowler.providers.image.image_provider import ImageProvider from tests.providers.image.image_fixtures import ( SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, SAMPLE_IMAGE_SHA, SAMPLE_MISCONFIGURATION_FINDING, SAMPLE_NON_CVE_VULNERABILITY_FINDING, SAMPLE_SECRET_FINDING, SAMPLE_UNKNOWN_SEVERITY_FINDING, SAMPLE_VULNERABILITY_FINDING, SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, get_empty_trivy_output, get_invalid_trivy_output, get_multi_type_trivy_output, get_no_metadata_trivy_output, get_repo_digest_only_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", "misconfig"] assert provider.image_config_scanners == [] 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 (alpine 3.18.0)", image_sha="c1aabb73d233", ) 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 == "container-image" 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" assert report.image_sha == "c1aabb73d233" assert report.resource_details == "alpine:3.18 (alpine 3.18.0)" assert report.region == "container" assert report.check_metadata.Categories == ["vulnerabilities"] assert report.check_metadata.RelatedUrl == "" def test_process_finding_vulnerability_prefers_cve_reference_and_filters_aqua(self): """Test CVE findings use cve.org and exclude Aqua references.""" provider = _make_provider() report = provider._process_finding( SAMPLE_VULNERABILITY_FINDING, "alpine:3.18", "alpine:3.18 (alpine 3.18.0)", ) assert ( report.check_metadata.Remediation.Recommendation.Url == "https://www.cve.org/CVERecord?id=CVE-2024-1234" ) assert report.check_metadata.AdditionalURLs == [ "https://www.cve.org/CVERecord?id=CVE-2024-1234" ] def test_process_finding_vulnerability_builds_cve_org_when_only_nvd_reference( self, ): """Test official CVE URL is built when only NVD is provided.""" provider = _make_provider() report = provider._process_finding( SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, "alpine:3.18", "alpine:3.18 (alpine 3.18.0)", ) assert ( report.check_metadata.Remediation.Recommendation.Url == "https://www.cve.org/CVERecord?id=CVE-2024-5678" ) assert report.check_metadata.AdditionalURLs == [ "https://www.cve.org/CVERecord?id=CVE-2024-5678" ] def test_process_finding_vulnerability_builds_cve_org_when_references_missing(self): """Test CVE URL is built from VulnerabilityID when references are absent.""" provider = _make_provider() report = provider._process_finding( SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, "alpine:3.18", "alpine:3.18 (alpine 3.18.0)", ) assert ( report.check_metadata.Remediation.Recommendation.Url == "https://www.cve.org/CVERecord?id=CVE-2024-9012" ) assert report.check_metadata.AdditionalURLs == [ "https://www.cve.org/CVERecord?id=CVE-2024-9012" ] def test_process_finding_non_cve_vulnerability_does_not_fallback_to_aqua(self): """Test non-CVE vulnerabilities do not keep Aqua links.""" provider = _make_provider() report = provider._process_finding( SAMPLE_NON_CVE_VULNERABILITY_FINDING, "alpine:3.18", "alpine:3.18 (alpine 3.18.0)", ) assert report.check_metadata.Remediation.Recommendation.Url == "" assert report.check_metadata.AdditionalURLs == [ "https://github.com/advisories/GHSA-abcd-1234-efgh" ] 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", "myimage:latest (debian 12)", ) 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 == "container-image" assert report.check_metadata.Categories == ["secrets"] 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", "myimage:latest (debian 12)", ) assert isinstance(report, CheckReportImage) assert report.check_metadata.CheckID == "DS001" assert report.check_metadata.Severity == "medium" assert report.check_metadata.ServiceName == "container-image" assert report.check_metadata.Categories == [] 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", "myimage:latest (alpine 3.18.0)", ) 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( returncode=0, 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" assert reports[0].image_sha == SAMPLE_IMAGE_SHA assert reports[0].resource_name == "alpine:3.18" assert reports[0].check_metadata.ServiceName == "container-image" @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( returncode=0, 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( returncode=0, 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( returncode=0, 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( returncode=0, 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("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_success(self, mock_factory): """Test successful connection returns is_connected=True.""" mock_adapter = MagicMock() mock_adapter.list_tags.return_value = ["3.18", "latest"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection(image="alpine:3.18") assert result.is_connected is True mock_adapter.list_tags.assert_called_once_with("library/alpine") @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_auth_failure(self, mock_factory): """Test registry auth error returns auth failure.""" mock_factory.return_value = MagicMock( list_tags=MagicMock(side_effect=ImageRegistryAuthError(file=__file__)) ) result = ImageProvider.test_connection(image="private/image:latest") assert result.is_connected is False assert "Authentication failed" in result.error @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_not_found(self, mock_factory): """Test tag not found returns not found error.""" mock_adapter = MagicMock() mock_adapter.list_tags.return_value = ["v1", "v2"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection(image="nonexistent/image:latest") assert result.is_connected is False assert "not found" in result.error @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_registry_url(self, mock_factory): """Test registry URL (namespace) uses list_repositories.""" mock_adapter = MagicMock() mock_adapter.list_repositories.return_value = ["andoniaf/myapp"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection(image="docker.io/andoniaf") assert result.is_connected is True mock_factory.assert_called_once_with( registry_url="docker.io/andoniaf", username=None, password=None, token=None, ) mock_adapter.list_repositories.assert_called_once() @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_registry_url_with_https_scheme(self, mock_factory): """Registry URL with https:// scheme is normalised before adapter creation.""" mock_adapter = MagicMock() mock_adapter.list_repositories.return_value = ["repo1"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection(image="https://my-registry.example.com") assert result.is_connected is True mock_factory.assert_called_once_with( registry_url="my-registry.example.com", username=None, password=None, token=None, ) mock_adapter.list_repositories.assert_called_once() 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( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) reports = provider.run() assert isinstance(reports, list) assert len(reports) == 1 @patch("subprocess.run") def test_scan_single_image_trivy_nonzero_exit(self, mock_subprocess): """Test that a non-zero Trivy exit code raises ImageScanError.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=1, stdout="", stderr="fatal error: unable to pull image", ) with pytest.raises(ImageScanError): for _ in provider._scan_single_image("alpine:3.18"): pass @patch("subprocess.run") def test_scan_single_image_auth_failure(self, mock_subprocess): """Test that a 401 unauthorized stderr raises ImageScanError with message.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=1, stdout="", stderr="ERROR 401 unauthorized: authentication required", ) with pytest.raises(ImageScanError, match="401 unauthorized"): for _ in provider._scan_single_image("private/image:latest"): pass @patch("subprocess.run") def test_sha_extraction_from_image_id(self, mock_subprocess): """Test that image_sha is extracted from Trivy Metadata.ImageID.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) reports = [] for batch in provider._scan_single_image("alpine:3.18"): reports.extend(batch) assert len(reports) == 1 assert reports[0].image_sha == SAMPLE_IMAGE_SHA @patch("subprocess.run") def test_sha_extraction_fallback_to_repo_digests(self, mock_subprocess): """Test that image_sha falls back to RepoDigests when ImageID is absent.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_repo_digest_only_trivy_output(), stderr="" ) reports = [] for batch in provider._scan_single_image("alpine:3.18"): reports.extend(batch) assert len(reports) == 1 assert reports[0].image_sha == "e5f6g7h8i9j0" @patch("subprocess.run") def test_sha_extraction_no_metadata(self, mock_subprocess): """Test that image_sha is empty when no Metadata is present.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_no_metadata_trivy_output(), stderr="" ) reports = [] for batch in provider._scan_single_image("alpine:3.18"): reports.extend(batch) assert len(reports) == 1 assert reports[0].image_sha == "" @patch("subprocess.run") def test_run_scan_propagates_scan_error(self, mock_subprocess): """Test that run_scan() re-raises ImageScanError instead of swallowing it.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=1, stdout="", stderr="image not found", ) with pytest.raises(ImageScanError): for _ in provider.run_scan(): pass class TestImageProviderRegistryAuth: def test_no_auth_by_default(self): """Test that no auth is set when no credentials are provided.""" provider = _make_provider() assert provider.registry_username is None assert provider.registry_password is None assert provider.registry_token is None assert provider.auth_method == "No auth" def test_basic_auth_with_explicit_params(self): """Test basic auth via explicit constructor params.""" provider = _make_provider( registry_username="myuser", registry_password="mypass", ) assert provider.registry_username == "myuser" assert provider.registry_password == "mypass" assert provider.auth_method == "Docker login" def test_token_auth_with_explicit_param(self): """Test token auth via explicit constructor param.""" provider = _make_provider(registry_token="my-token-123") assert provider.registry_token == "my-token-123" assert provider.auth_method == "Registry token" def test_basic_auth_takes_precedence_over_token(self): """Test that username/password takes precedence over token.""" provider = _make_provider( registry_username="myuser", registry_password="mypass", registry_token="my-token", ) assert provider.auth_method == "Docker login" @patch.dict( os.environ, {"REGISTRY_USERNAME": "envuser", "REGISTRY_PASSWORD": "envpass"} ) def test_basic_auth_from_env_vars(self): """Test that env vars are used as fallback for basic auth.""" provider = _make_provider() assert provider.registry_username == "envuser" assert provider.registry_password == "envpass" assert provider.auth_method == "Docker login" @patch.dict(os.environ, {"REGISTRY_TOKEN": "env-token"}) def test_token_auth_from_env_var(self): """Test that env var is used as fallback for token auth.""" provider = _make_provider() assert provider.registry_token == "env-token" assert provider.auth_method == "Registry token" @patch.dict( os.environ, {"REGISTRY_USERNAME": "envuser", "REGISTRY_PASSWORD": "envpass"} ) def test_explicit_params_override_env_vars(self): """Test that explicit params take precedence over env vars.""" provider = _make_provider( registry_username="explicit", registry_password="explicit-pass", ) assert provider.registry_username == "explicit" assert provider.registry_password == "explicit-pass" def test_build_trivy_env_no_auth(self): """Test that _build_trivy_env returns base env when no auth.""" provider = _make_provider() env = provider._build_trivy_env() assert "TRIVY_USERNAME" not in env assert "TRIVY_PASSWORD" not in env assert "TRIVY_REGISTRY_TOKEN" not in env def test_build_trivy_env_basic_auth_sets_env_vars(self): """Test that _build_trivy_env injects TRIVY_USERNAME/PASSWORD for native Trivy auth.""" provider = _make_provider( registry_username="myuser", registry_password="mypass", ) env = provider._build_trivy_env() assert env["TRIVY_USERNAME"] == "myuser" assert env["TRIVY_PASSWORD"] == "mypass" def test_build_trivy_env_token_auth(self): """Test that _build_trivy_env injects registry token.""" provider = _make_provider(registry_token="my-token") env = provider._build_trivy_env() assert env["TRIVY_REGISTRY_TOKEN"] == "my-token" @patch("subprocess.run") def test_execute_trivy_sets_trivy_env_with_basic_auth(self, mock_subprocess): """Test that _execute_trivy sets TRIVY_USERNAME/PASSWORD for native Trivy auth.""" provider = _make_provider( registry_username="myuser", registry_password="mypass", ) mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) provider._execute_trivy(["trivy", "image", "alpine:3.18"], "alpine:3.18") call_kwargs = mock_subprocess.call_args env = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env") assert env["TRIVY_USERNAME"] == "myuser" assert env["TRIVY_PASSWORD"] == "mypass" @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_with_basic_auth(self, mock_factory): """Test test_connection passes credentials to the registry adapter.""" mock_adapter = MagicMock() mock_adapter.list_tags.return_value = ["v1"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection( image="private.registry.io/myapp:v1", registry_username="myuser", registry_password="mypass", ) assert result.is_connected is True mock_factory.assert_called_once_with( registry_url="private.registry.io", username="myuser", password="mypass", token=None, ) @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_test_connection_with_token(self, mock_factory): """Test test_connection passes token to the registry adapter.""" mock_adapter = MagicMock() mock_adapter.list_tags.return_value = ["v1"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection( image="private.registry.io/myapp:v1", registry_token="my-token", ) assert result.is_connected is True mock_factory.assert_called_once_with( registry_url="private.registry.io", username=None, password=None, token="my-token", ) def test_print_credentials_shows_auth_method(self): """Test that print_credentials outputs the auth method.""" provider = _make_provider( registry_username="myuser", registry_password="mypass", ) 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 "Docker login" in output class TestStripScheme: @pytest.mark.parametrize( "raw,expected", [ ("https://my-registry.example.com", "my-registry.example.com"), ("http://my-registry.example.com", "my-registry.example.com"), ("HTTPS://My-Registry.Example.Com", "My-Registry.Example.Com"), ("Http://localhost:5000", "localhost:5000"), ("my-registry.example.com", "my-registry.example.com"), ("https://", ""), ("https://https://nested.example.com", "https://nested.example.com"), ( "ftp://not-a-supported-scheme.example.com", "ftp://not-a-supported-scheme.example.com", ), ], ) def test_strip_scheme(self, raw, expected): assert ImageProvider._strip_scheme(raw) == expected class TestExtractRegistry: def test_docker_hub_simple(self): assert ImageProvider._extract_registry("alpine:3.18") is None def test_docker_hub_with_namespace(self): assert ImageProvider._extract_registry("andoniaf/test-private:tag") is None def test_ghcr(self): assert ImageProvider._extract_registry("ghcr.io/user/image:tag") == "ghcr.io" def test_ecr(self): assert ( ImageProvider._extract_registry( "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo:tag" ) == "123456789012.dkr.ecr.us-east-1.amazonaws.com" ) def test_localhost_with_port(self): assert ( ImageProvider._extract_registry("localhost:5000/myimage:latest") == "localhost:5000" ) def test_custom_registry_with_port(self): assert ( ImageProvider._extract_registry("myregistry.io:5000/image:tag") == "myregistry.io:5000" ) def test_digest_reference(self): assert ( ImageProvider._extract_registry("ghcr.io/user/image@sha256:abc123") == "ghcr.io" ) def test_bare_image_name(self): assert ImageProvider._extract_registry("nginx") is None def test_https_scheme_bare_hostname_returns_none(self): """Bare scheme-prefixed hostname has no image path, so no registry is extracted.""" assert ( ImageProvider._extract_registry("https://my-registry.example.com") is None ) def test_http_scheme_with_port_stripped(self): assert ( ImageProvider._extract_registry("http://localhost:5000/myimage:latest") == "localhost:5000" ) def test_https_scheme_with_path_stripped(self): assert ( ImageProvider._extract_registry("https://ghcr.io/org/image:tag") == "ghcr.io" ) class TestIsRegistryUrl: def test_bare_ecr_hostname(self): assert ImageProvider._is_registry_url( "714274078102.dkr.ecr.eu-west-1.amazonaws.com" ) def test_bare_hostname_with_port(self): assert ImageProvider._is_registry_url("myregistry.com:5000") def test_bare_ghcr(self): assert ImageProvider._is_registry_url("ghcr.io") def test_registry_with_namespace_only(self): """Registry URL with a single path segment (no tag) is a registry URL.""" assert ImageProvider._is_registry_url("ghcr.io/myorg") def test_image_reference_not_registry(self): """Full image reference with repo and tag is not a registry URL.""" assert not ImageProvider._is_registry_url("ghcr.io/myorg/repo:tag") def test_simple_image_name(self): assert not ImageProvider._is_registry_url("alpine:3.18") def test_bare_image_no_tag(self): assert not ImageProvider._is_registry_url("nginx") def test_dockerhub_namespace(self): assert not ImageProvider._is_registry_url("library/alpine") def test_https_scheme_bare_hostname(self): assert ImageProvider._is_registry_url("https://my-registry.example.com") def test_http_scheme_bare_hostname_with_port(self): assert ImageProvider._is_registry_url("http://my-registry.example.com:5000") def test_https_scheme_image_reference_not_registry(self): """A scheme-prefixed full image reference is still an image, not a registry URL.""" assert not ImageProvider._is_registry_url("https://ghcr.io/myorg/repo:tag") class TestTestRegistryConnection: @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_registry_connection_success(self, mock_factory): """Test that a bare hostname triggers registry catalog test.""" mock_adapter = MagicMock() mock_adapter.list_repositories.return_value = ["repo1"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection( image="714274078102.dkr.ecr.eu-west-1.amazonaws.com", registry_username="user", registry_password="pass", ) assert result.is_connected is True mock_factory.assert_called_once_with( registry_url="714274078102.dkr.ecr.eu-west-1.amazonaws.com", username="user", password="pass", token=None, ) mock_adapter.list_repositories.assert_called_once() @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_registry_connection_auth_failure(self, mock_factory): """Test that 401 from registry adapter returns auth failure.""" mock_adapter = MagicMock() mock_adapter.list_repositories.side_effect = Exception("401 unauthorized") mock_factory.return_value = mock_adapter result = ImageProvider.test_connection( image="714274078102.dkr.ecr.eu-west-1.amazonaws.com", ) assert result.is_connected is False assert "Authentication failed" in result.error @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_registry_connection_generic_error(self, mock_factory): """Test that a generic error from registry adapter returns error message.""" mock_adapter = MagicMock() mock_adapter.list_repositories.side_effect = Exception("connection refused") mock_factory.return_value = mock_adapter result = ImageProvider.test_connection( image="myregistry.example.com", ) assert result.is_connected is False assert "Failed to connect to registry" in result.error @patch("prowler.providers.image.image_provider.create_registry_adapter") def test_image_reference_uses_registry_adapter(self, mock_factory): """Test that a full image reference uses registry adapter to verify tag.""" mock_adapter = MagicMock() mock_adapter.list_tags.return_value = ["3.18", "latest"] mock_factory.return_value = mock_adapter result = ImageProvider.test_connection(image="alpine:3.18") assert result.is_connected is True mock_adapter.list_tags.assert_called_once() class TestTrivyAuthIntegration: @patch("subprocess.run") def test_run_scan_passes_trivy_env_with_credentials(self, mock_subprocess): """Test that run_scan() passes TRIVY_USERNAME/PASSWORD via env when credentials are set.""" mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) provider = _make_provider( images=["ghcr.io/user/image:tag"], registry_username="myuser", registry_password="mypass", ) list(provider.run_scan()) call_kwargs = mock_subprocess.call_args env = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env") assert env["TRIVY_USERNAME"] == "myuser" assert env["TRIVY_PASSWORD"] == "mypass" def test_registry_url_ghcr(self): assert ImageProvider._is_registry_url("ghcr.io/org") is True def test_image_ref_with_tag(self): assert ImageProvider._is_registry_url("ghcr.io/user/image:tag") is False def test_image_ref_with_repo(self): assert ImageProvider._is_registry_url("ghcr.io/user/image") is False def test_dockerhub_short_image(self): assert ImageProvider._is_registry_url("alpine:3.18") is False def test_dockerhub_with_namespace(self): assert ImageProvider._is_registry_url("andoniaf/test:tag") is False def test_bare_image_name(self): assert ImageProvider._is_registry_url("nginx") is False def test_localhost_namespace(self): assert ImageProvider._is_registry_url("localhost:5000/myns") is True def test_localhost_image_with_tag(self): assert ImageProvider._is_registry_url("localhost:5000/myns/image:v1") is False class TestCleanup: def test_cleanup_idempotent(self): """Test cleanup is safe to call multiple times.""" provider = _make_provider() provider.cleanup() provider.cleanup() def test_cleanup_removes_trivy_cache_dir(self): """Test that cleanup removes the temporary Trivy cache directory.""" provider = _make_provider() cache_dir = provider._trivy_cache_dir assert os.path.isdir(cache_dir) provider.cleanup() assert not os.path.isdir(cache_dir) class TestImageProviderInputValidation: def test_invalid_timeout_format_raises_error(self): """Test that a non-matching timeout string raises ImageInvalidTimeoutError.""" with pytest.raises(ImageInvalidTimeoutError): _make_provider(timeout="invalid") def test_invalid_timeout_no_unit_raises_error(self): """Test that a numeric timeout without a unit raises ImageInvalidTimeoutError.""" with pytest.raises(ImageInvalidTimeoutError): _make_provider(timeout="300") def test_invalid_timeout_wrong_unit_raises_error(self): """Test that a timeout with an unsupported unit raises ImageInvalidTimeoutError.""" with pytest.raises(ImageInvalidTimeoutError): _make_provider(timeout="5d") def test_valid_timeout_seconds(self): """Test that a seconds-based timeout is accepted.""" provider = _make_provider(timeout="300s") assert provider.timeout == "300s" def test_valid_timeout_hours(self): """Test that an hours-based timeout is accepted.""" provider = _make_provider(timeout="1h") assert provider.timeout == "1h" def test_invalid_scanner_raises_error(self): """Test that an invalid scanner name raises ImageInvalidScannerError.""" with pytest.raises(ImageInvalidScannerError): _make_provider(scanners=["vuln", "bad"]) def test_invalid_severity_raises_error(self): """Test that an invalid severity level raises ImageInvalidSeverityError.""" with pytest.raises(ImageInvalidSeverityError): _make_provider(trivy_severity=["HIGH", "SUPER_HIGH"]) def test_valid_all_scanners(self): """Test that all valid scanner choices are accepted.""" provider = _make_provider(scanners=["vuln", "secret", "misconfig", "license"]) assert provider.scanners == ["vuln", "secret", "misconfig", "license"] def test_valid_all_severities(self): """Test that all valid severity choices are accepted.""" provider = _make_provider( trivy_severity=["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"] ) assert provider.trivy_severity == [ "CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN", ] def test_image_config_scanners_defaults_to_empty(self): """Test that image_config_scanners defaults to an empty list.""" provider = _make_provider() assert provider.image_config_scanners == [] def test_valid_image_config_scanners(self): """Test that valid image config scanners are accepted.""" provider = _make_provider(image_config_scanners=["misconfig", "secret"]) assert provider.image_config_scanners == ["misconfig", "secret"] def test_invalid_image_config_scanner_raises_error(self): """Test that an invalid image config scanner raises ImageInvalidConfigScannerError.""" with pytest.raises(ImageInvalidConfigScannerError): _make_provider(image_config_scanners=["misconfig", "vuln"]) @patch("subprocess.run") def test_trivy_command_includes_cache_dir(self, mock_subprocess): """Test that Trivy command includes --cache-dir for cache isolation.""" provider = _make_provider() mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_empty_trivy_output(), stderr="" ) for _ in provider._scan_single_image("alpine:3.18"): pass call_args = mock_subprocess.call_args[0][0] assert "--cache-dir" in call_args idx = call_args.index("--cache-dir") assert call_args[idx + 1] == provider._trivy_cache_dir @patch("subprocess.run") def test_trivy_command_includes_image_config_scanners(self, mock_subprocess): """Test that Trivy command includes --image-config-scanners when set.""" provider = _make_provider(image_config_scanners=["misconfig", "secret"]) mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_empty_trivy_output(), stderr="" ) for _ in provider._scan_single_image("alpine:3.18"): pass call_args = mock_subprocess.call_args[0][0] assert "--image-config-scanners" in call_args idx = call_args.index("--image-config-scanners") assert call_args[idx + 1] == "misconfig,secret" @patch("subprocess.run") def test_trivy_command_omits_image_config_scanners_when_empty( self, mock_subprocess ): """Test that Trivy command omits --image-config-scanners when empty.""" provider = _make_provider(image_config_scanners=[]) mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_empty_trivy_output(), stderr="" ) for _ in provider._scan_single_image("alpine:3.18"): pass call_args = mock_subprocess.call_args[0][0] assert "--image-config-scanners" not in call_args class TestImageProviderErrorCategorization: def test_categorize_auth_failure(self): """Test that auth-related errors are categorized correctly.""" result = ImageProvider._categorize_trivy_error( "401 unauthorized: access denied" ) assert "Auth failure" in result def test_categorize_not_found(self): """Test that not-found errors are categorized correctly.""" result = ImageProvider._categorize_trivy_error( "manifest unknown: image not found" ) assert "Image not found" in result def test_categorize_rate_limit(self): """Test that rate-limit errors are categorized correctly.""" result = ImageProvider._categorize_trivy_error("429 too many requests") assert "Rate limited" in result def test_categorize_network_issue(self): """Test that network errors are categorized correctly.""" result = ImageProvider._categorize_trivy_error("connection refused to registry") assert "Network issue" in result def test_categorize_unknown_error(self): """Test that unrecognized errors are returned as-is.""" msg = "some unknown trivy error" result = ImageProvider._categorize_trivy_error(msg) assert result == msg class TestImageProviderNameValidation: @pytest.mark.parametrize( "bad_name", [ "alpine;rm -rf /", "image|cat /etc/passwd", "image&background", "image$VAR", "image`whoami`", "image\ninjected", "image\rinjected", ], ) def test_image_provider_invalid_image_name_shell_chars(self, bad_name): """Test that image names with shell metacharacters raise ImageInvalidNameError.""" with pytest.raises(ImageInvalidNameError): _make_provider(images=[bad_name]) def test_image_provider_invalid_image_name_empty(self): """Test that an empty string image name raises ImageInvalidNameError.""" with pytest.raises(ImageInvalidNameError): _make_provider(images=[""]) @pytest.mark.parametrize( "valid_name", [ "alpine:3.18", "nginx:latest", "registry.example.com/repo/image:tag", "ghcr.io/owner/image:v1.2.3", "myimage@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", "localhost:5000/myimage:latest", ], ) def test_image_provider_valid_image_names(self, valid_name): """Test that various valid image name formats pass validation.""" provider = _make_provider(images=[valid_name]) assert valid_name in provider.images def test_image_provider_image_name_too_long(self): """Test that a name exceeding 500 chars raises ImageInvalidNameError.""" long_name = "a" * 501 with pytest.raises(ImageInvalidNameError): _make_provider(images=[long_name]) def test_image_provider_file_too_many_lines(self): """Test that a file with more than MAX_IMAGE_LIST_LINES raises ImageListFileReadError.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: for i in range(10_001): f.write(f"image{i}:latest\n") f.flush() file_path = f.name with pytest.raises(ImageListFileReadError): _make_provider(images=None, image_list_file=file_path) class TestScanPerImage: @patch("subprocess.run") def test_yields_per_image(self, mock_subprocess): """Test that scan_per_image yields (name, findings) per image.""" mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) provider = _make_provider(images=["alpine:3.18", "nginx:latest"]) results = list(provider.scan_per_image()) assert len(results) == 2 for name, findings in results: assert isinstance(name, str) assert isinstance(findings, list) assert all(isinstance(f, CheckReportImage) for f in findings) @patch("subprocess.run") def test_reraises_scan_error(self, mock_subprocess): """Test that ImageScanError propagates from scan_per_image.""" mock_subprocess.return_value = MagicMock( returncode=1, stdout="", stderr="scan failed" ) provider = _make_provider(images=["alpine:3.18"]) with pytest.raises(ImageScanError): list(provider.scan_per_image()) @patch("subprocess.run") def test_skips_generic_error(self, mock_subprocess): """Test that a generic RuntimeError in _scan_single_image yields empty findings and continues.""" def side_effect(cmd, **kwargs): if "bad:image" in cmd: raise RuntimeError("unexpected error") return MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) mock_subprocess.side_effect = side_effect provider = _make_provider(images=["bad:image", "alpine:3.18"]) results = list(provider.scan_per_image()) assert len(results) == 2 assert results[0][0] == "bad:image" assert results[0][1] == [] assert results[1][0] == "alpine:3.18" assert len(results[1][1]) > 0 @patch("subprocess.run") def test_calls_cleanup(self, mock_subprocess): """Test that cleanup is called even after scan_per_image completes.""" mock_subprocess.return_value = MagicMock( returncode=0, stdout=get_sample_trivy_json_output(), stderr="" ) provider = _make_provider(images=["alpine:3.18"]) with mock.patch.object(provider, "cleanup") as mock_cleanup: list(provider.scan_per_image()) mock_cleanup.assert_called_once() class TestInitGlobalProviderRegistryEnumeration: """Regression test: `prowler image --registry` must discover images. PR #9985 added registry scan support. PR #10128 accidentally removed the registry kwargs from the init_global_provider call, so the CLI parsed --registry but never forwarded it to ImageProvider. The result was that registry enumeration silently never ran and the provider raised ImageNoImagesProvidedError. """ @patch("prowler.providers.image.image_provider.create_registry_adapter") @patch("prowler.providers.common.provider.load_and_validate_config_file") def test_cli_registry_flag_discovers_images( self, mock_load_config, mock_adapter_factory ): """Verify that `prowler image --registry myregistry.io --image-filter myorg/` actually discovers and populates images from the registry.""" mock_load_config.return_value = {} adapter = MagicMock() adapter.list_repositories.return_value = ["myorg/app", "myorg/api", "other/lib"] adapter.list_tags.side_effect = [["v1.0", "latest"], ["v2.0"], ["v1.0"]] mock_adapter_factory.return_value = adapter arguments = Namespace( provider="image", config_file=None, fixer_config=None, images=None, image_list_file=None, scanners=["vuln"], image_config_scanners=None, trivy_severity=None, ignore_unfixed=False, timeout="5m", registry="myregistry.io", image_filter="^myorg/", tag_filter=None, max_images=0, registry_insecure=False, registry_list_images=False, ) # Reset the global singleton so init_global_provider doesn't # short-circuit via the isinstance check. The patch restores the # original value automatically on exit. with mock.patch.object(Provider, "_global", None): Provider.init_global_provider(arguments) provider = Provider._global # Registry enumeration should have discovered images matching the filter assert "myregistry.io/myorg/app:v1.0" in provider.images assert "myregistry.io/myorg/app:latest" in provider.images assert "myregistry.io/myorg/api:v2.0" in provider.images # The "other/lib" repo should be filtered out by --image-filter assert not any("other/lib" in img for img in provider.images) assert len(provider.images) == 3 class TestRegistryListMode: """Regression test: `prowler image --registry --registry-list` crashes. When --registry-list is passed, ImageProvider._enumerate_registry sets _listing_only = True and __init__ returns early — before calling Provider.set_global_provider(self). The caller in __main__.py then calls global_provider.print_credentials() on a None reference, raising AttributeError: 'NoneType' object has no attribute 'print_credentials'. """ @patch("prowler.providers.image.image_provider.create_registry_adapter") @patch("prowler.providers.common.provider.load_and_validate_config_file") def test_registry_list_does_not_crash(self, mock_load_config, mock_adapter_factory): """Reproduce the --registry-list crash by running the same sequence as __main__.py: init_global_provider, get_global_provider, then print_credentials.""" mock_load_config.return_value = {} adapter = MagicMock() adapter.list_repositories.return_value = ["myorg/app"] adapter.list_tags.return_value = ["v1.0", "latest"] mock_adapter_factory.return_value = adapter arguments = Namespace( provider="image", config_file=None, fixer_config=None, images=None, image_list_file=None, scanners=["vuln"], image_config_scanners=None, trivy_severity=None, ignore_unfixed=False, timeout="5m", registry="myregistry.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False, registry_list_images=True, ) # Reproduce the exact crash sequence from __main__.py lines 289-294: # Provider.init_global_provider(args) # global_provider = Provider.get_global_provider() # global_provider.print_credentials() with mock.patch.object(Provider, "_global", None): Provider.init_global_provider(arguments) global_provider = Provider.get_global_provider() # This is the line that crashes: global_provider is None so # .print_credentials() raises AttributeError. global_provider.print_credentials()