From 876702fd22db3d4e503d79019586ad83d61aea95 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:44:41 +0100 Subject: [PATCH] feat(image): add --registry-list flag for image discovery Add --registry-list flag that enumerates registry repositories and tags, then prints a structured listing and exits without scanning. This helps users discover available images before building --image-filter or --tag-filter regexes. --- prowler/providers/common/provider.py | 3 + prowler/providers/image/image_provider.py | 29 ++++++++ .../image/lib/arguments/arguments.py | 11 +++ .../lib/registry/test_provider_registry.py | 69 +++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index ccb78a4d20..e43fa71c7d 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -295,6 +295,9 @@ class Provider(ABC): registry_insecure=getattr( arguments, "registry_insecure", False ), + registry_list_images=getattr( + arguments, "registry_list_images", False + ), ) elif "mongodbatlas" in provider_class_name.lower(): provider_class( diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index da435990c6..0738f1e7bc 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -80,6 +80,7 @@ class ImageProvider(Provider): tag_filter: str | None = None, max_images: int = 0, registry_insecure: bool = False, + registry_list_images: bool = False, ): logger.info("Instantiating Image Provider...") @@ -122,6 +123,7 @@ class ImageProvider(Provider): self.tag_filter = tag_filter self.max_images = max_images self.registry_insecure = registry_insecure + self.registry_list_images = registry_list_images # Compile regex filters self._image_filter_re = None @@ -819,6 +821,7 @@ class ImageProvider(Provider): is_dockerhub = isinstance(adapter, DockerHubAdapter) discovered_images = [] + repos_tags: dict[str, list[str]] = {} for repo in repositories: tags = adapter.list_tags(repo) @@ -826,6 +829,9 @@ class ImageProvider(Provider): if self._tag_filter_re: tags = [t for t in tags if self._tag_filter_re.search(t)] + if tags: + repos_tags[repo] = tags + for tag in tags: if is_dockerhub: # Docker Hub images don't need a host prefix @@ -840,6 +846,11 @@ class ImageProvider(Provider): image_ref = f"{registry_host}/{repo}:{tag}" discovered_images.append(image_ref) + # Registry list mode: print listing and exit + if self.registry_list_images: + self._print_registry_listing(repos_tags, len(discovered_images)) + raise SystemExit(0) + # Check max-images limit if self.max_images and len(discovered_images) > self.max_images: raise ImageMaxImagesExceededError( @@ -859,6 +870,24 @@ class ImageProvider(Provider): f"({len(repositories)} repositories). Total images to scan: {len(self.images)}" ) + def _print_registry_listing( + self, repos_tags: dict[str, list[str]], total_images: int + ) -> None: + """Print a structured listing of registry repositories and tags.""" + num_repos = len(repos_tags) + print( + f"\n{Style.BRIGHT}Registry:{Style.RESET_ALL} " + f"{Fore.CYAN}{self.registry}{Style.RESET_ALL} " + f"({num_repos} repositories, {total_images} images)\n" + ) + for repo, tags in repos_tags.items(): + print( + f" {Fore.YELLOW}{repo}{Style.RESET_ALL} " + f"({len(tags)} tags)" + ) + print(f" {', '.join(tags)}") + print() + def print_credentials(self) -> None: """Print scan configuration.""" report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}" diff --git a/prowler/providers/image/lib/arguments/arguments.py b/prowler/providers/image/lib/arguments/arguments.py index 41df7b92b8..3dfe9cf92b 100644 --- a/prowler/providers/image/lib/arguments/arguments.py +++ b/prowler/providers/image/lib/arguments/arguments.py @@ -122,6 +122,13 @@ def init_parser(self): default=False, help="Skip TLS verification for registry connections (for self-signed certificates).", ) + registry_group.add_argument( + "--registry-list", + dest="registry_list_images", + action="store_true", + default=False, + help="List all repositories and tags from the registry, then exit without scanning. Useful for discovering available images before building --image-filter or --tag-filter.", + ) def validate_arguments(arguments): @@ -133,6 +140,10 @@ def validate_arguments(arguments): tag_filter = getattr(arguments, "tag_filter", None) max_images = getattr(arguments, "max_images", 0) registry_insecure = getattr(arguments, "registry_insecure", False) + registry_list_images = getattr(arguments, "registry_list_images", False) + + if registry_list_images and not registry: + return (False, "--registry-list requires --registry.") if not images and not image_list_file and not registry: return ( diff --git a/tests/providers/image/lib/registry/test_provider_registry.py b/tests/providers/image/lib/registry/test_provider_registry.py index 3f5d2fc8b3..9b67b005b6 100644 --- a/tests/providers/image/lib/registry/test_provider_registry.py +++ b/tests/providers/image/lib/registry/test_provider_registry.py @@ -24,6 +24,7 @@ def _build_provider(**overrides): tag_filter=None, max_images=0, registry_insecure=False, + registry_list_images=False, config_content={"image": {}}, ) defaults.update(overrides) @@ -147,3 +148,71 @@ class TestEmptyRegistry: provider = _build_provider(images=["nginx:latest"]) assert provider.images == ["nginx:latest"] + + +class TestRegistryList: + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_list_prints_and_exits(self, mock_factory, capsys): + adapter = MagicMock() + adapter.list_repositories.return_value = ["app/frontend", "app/backend"] + adapter.list_tags.side_effect = [["latest", "v1.0"], ["latest"]] + mock_factory.return_value = adapter + + with pytest.raises(SystemExit) as exc_info: + _build_provider(registry_list_images=True) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "app/frontend" in captured.out + assert "app/backend" in captured.out + assert "latest" in captured.out + assert "v1.0" in captured.out + assert "2 repositories" in captured.out + assert "3 images" in captured.out + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_list_respects_image_filter(self, mock_factory, capsys): + adapter = MagicMock() + adapter.list_repositories.return_value = ["prod/app", "dev/app"] + adapter.list_tags.return_value = ["latest"] + mock_factory.return_value = adapter + + with pytest.raises(SystemExit) as exc_info: + _build_provider(registry_list_images=True, image_filter="^prod/") + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "prod/app" in captured.out + assert "dev/app" not in captured.out + assert "1 repositories" in captured.out + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_list_respects_tag_filter(self, mock_factory, capsys): + adapter = MagicMock() + adapter.list_repositories.return_value = ["myapp"] + adapter.list_tags.return_value = ["latest", "v1.0", "dev-abc"] + mock_factory.return_value = adapter + + with pytest.raises(SystemExit) as exc_info: + _build_provider(registry_list_images=True, tag_filter=r"^v\d+\.\d+$") + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "v1.0" in captured.out + assert "dev-abc" not in captured.out + assert "1 images" in captured.out + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_list_skips_max_images(self, mock_factory, capsys): + adapter = MagicMock() + adapter.list_repositories.return_value = ["app1", "app2", "app3"] + adapter.list_tags.return_value = ["latest", "v1.0"] + mock_factory.return_value = adapter + + # max_images=1 would normally raise, but --registry-list skips it + with pytest.raises(SystemExit) as exc_info: + _build_provider(registry_list_images=True, max_images=1) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "6 images" in captured.out