Skip to content

Commit 451071d

Browse files
andoniafalejandrobailoclaude
authored
feat(image): add image provider to UI (#10167)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
1 parent 887a20f commit 451071d

File tree

25 files changed

+551
-61
lines changed

25 files changed

+551
-61
lines changed

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
66

77
### 🚀 Added
88

9+
- `misconfig` scanner as default for Image provider scans [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167)
910
- `entra_conditional_access_policy_device_code_flow_blocked` check for M365 provider [(#10218)](https://github.com/prowler-cloud/prowler/pull/10218)
1011
- CheckMetadata Pydantic validators [(#8584)](https://github.com/prowler-cloud/prowler/pull/8583)
1112
- `entra_conditional_access_policy_require_mfa_for_admin_portals` check for Azure provider and update CIS compliance [(#10330)](https://github.com/prowler-cloud/prowler/pull/10330)

prowler/providers/image/image_provider.py

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
import subprocess
77
import sys
8+
import tempfile
89
from typing import Generator
910

1011
from alive_progress import alive_bar
@@ -88,7 +89,9 @@ def __init__(
8889

8990
self.images = images if images is not None else []
9091
self.image_list_file = image_list_file
91-
self.scanners = scanners if scanners is not None else ["vuln", "secret"]
92+
self.scanners = (
93+
scanners if scanners is not None else ["vuln", "secret", "misconfig"]
94+
)
9295
self.image_config_scanners = (
9396
image_config_scanners if image_config_scanners is not None else []
9497
)
@@ -100,6 +103,10 @@ def __init__(
100103
self._session = None
101104
self._identity = "prowler"
102105
self._listing_only = False
106+
self._trivy_cache_dir_obj = tempfile.TemporaryDirectory(
107+
prefix="prowler-trivy-cache-"
108+
)
109+
self._trivy_cache_dir = self._trivy_cache_dir_obj.name
103110

104111
# Registry authentication (follows IaC pattern: explicit params, env vars internal)
105112
self.registry_username = registry_username or os.environ.get(
@@ -329,9 +336,15 @@ def _extract_registry(image: str) -> str | None:
329336
def _is_registry_url(image_uid: str) -> bool:
330337
"""Determine whether an image UID is a registry URL (namespace only).
331338
332-
A registry URL like ``docker.io/andoniaf`` has a registry host but
333-
the remaining part contains no ``/`` (no repo) and no ``:`` (no tag).
339+
Bare hostnames like "714274078102.dkr.ecr.eu-west-1.amazonaws.com"
340+
or "myregistry.com:5000" are registry URLs (dots in host, no slash).
341+
Image references like "alpine:3.18" or "nginx" are not.
334342
"""
343+
if "/" not in image_uid:
344+
host_part = image_uid.split(":")[0]
345+
if "." in host_part:
346+
return True
347+
335348
registry_host = ImageProvider._extract_registry(image_uid)
336349
if not registry_host:
337350
return False
@@ -340,6 +353,8 @@ def _is_registry_url(image_uid: str) -> bool:
340353

341354
def cleanup(self) -> None:
342355
"""Clean up any resources after scanning."""
356+
if hasattr(self, "_trivy_cache_dir_obj"):
357+
self._trivy_cache_dir_obj.cleanup()
343358

344359
def _process_finding(
345360
self,
@@ -540,6 +555,8 @@ def _scan_single_image(
540555
trivy_command = [
541556
"trivy",
542557
"image",
558+
"--cache-dir",
559+
self._trivy_cache_dir,
543560
"--format",
544561
"json",
545562
"--scanners",
@@ -928,6 +945,9 @@ def test_connection(
928945
Uses registry HTTP APIs directly instead of Trivy to avoid false
929946
failures caused by Trivy DB download issues.
930947
948+
For bare registry hostnames (e.g. ECR URLs passed by the API as provider_uid),
949+
uses the OCI catalog endpoint instead of trivy image.
950+
931951
Args:
932952
image: Container image or registry URL to test
933953
raise_on_exception: Whether to raise exceptions
@@ -946,32 +966,34 @@ def test_connection(
946966
if not image:
947967
return Connection(is_connected=False, error="Image name is required")
948968

969+
# Registry URL (bare hostname) → test via OCI catalog
949970
if ImageProvider._is_registry_url(image):
950-
# Registry enumeration mode — test by listing repositories
951-
adapter = create_registry_adapter(
971+
return ImageProvider._test_registry_connection(
952972
registry_url=image,
953-
username=registry_username,
954-
password=registry_password,
955-
token=registry_token,
973+
registry_username=registry_username,
974+
registry_password=registry_password,
975+
registry_token=registry_token,
956976
)
957-
adapter.list_repositories()
958-
return Connection(is_connected=True)
959977

960-
# Image reference mode — verify the specific tag exists
978+
# Image reference verify tag exists via registry API
961979
registry_host = ImageProvider._extract_registry(image)
962-
repo_and_tag = image[len(registry_host) + 1 :] if registry_host else image
963-
if ":" in repo_and_tag:
964-
repository, tag = repo_and_tag.rsplit(":", 1)
965-
else:
966-
repository = repo_and_tag
967-
tag = "latest"
968-
969-
is_dockerhub = not registry_host or registry_host in (
980+
is_dockerhub = registry_host is None or registry_host in (
970981
"docker.io",
971982
"registry-1.docker.io",
972983
)
973984

974-
# Docker Hub official images use "library/" prefix
985+
# Parse repository and tag from the image reference
986+
ref = image.rsplit("@", 1)[0] if "@" in image else image
987+
last_segment = ref.split("/")[-1]
988+
if ":" in last_segment:
989+
tag = last_segment.split(":")[-1]
990+
base = ref[: -(len(tag) + 1)]
991+
else:
992+
tag = "latest"
993+
base = ref
994+
995+
repository = base[len(registry_host) + 1 :] if registry_host else base
996+
975997
if is_dockerhub and "/" not in repository:
976998
repository = f"library/{repository}"
977999

@@ -1013,3 +1035,37 @@ def test_connection(
10131035
is_connected=False,
10141036
error=f"Unexpected error: {str(error)}",
10151037
)
1038+
1039+
@staticmethod
1040+
def _test_registry_connection(
1041+
registry_url: str,
1042+
registry_username: str | None = None,
1043+
registry_password: str | None = None,
1044+
registry_token: str | None = None,
1045+
) -> "Connection":
1046+
"""Test connection to a registry URL by listing repositories via OCI catalog."""
1047+
try:
1048+
adapter = create_registry_adapter(
1049+
registry_url=registry_url,
1050+
username=registry_username,
1051+
password=registry_password,
1052+
token=registry_token,
1053+
)
1054+
adapter.list_repositories()
1055+
return Connection(is_connected=True)
1056+
except Exception as error:
1057+
error_str = str(error).lower()
1058+
if "401" in error_str or "unauthorized" in error_str:
1059+
return Connection(
1060+
is_connected=False,
1061+
error="Authentication failed. Check registry credentials.",
1062+
)
1063+
elif "404" in error_str or "not found" in error_str:
1064+
return Connection(
1065+
is_connected=False,
1066+
error="Registry catalog not found.",
1067+
)
1068+
return Connection(
1069+
is_connected=False,
1070+
error=f"Failed to connect to registry: {str(error)[:200]}",
1071+
)

prowler/providers/image/lib/arguments/arguments.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ def init_parser(self):
5050
"--scanner",
5151
dest="scanners",
5252
nargs="+",
53-
default=["vuln", "secret"],
53+
default=["vuln", "secret", "misconfig"],
5454
choices=SCANNERS_CHOICES,
55-
help="Trivy scanners to use. Default: vuln, secret. Available: vuln, secret, misconfig, license",
55+
help="Trivy scanners to use. Default: vuln, secret, misconfig. Available: vuln, secret, misconfig, license",
5656
)
5757

5858
scan_config_group.add_argument(

tests/providers/image/image_provider_test.py

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_image_provider(self):
5353
assert provider._type == "image"
5454
assert provider.type == "image"
5555
assert provider.images == ["alpine:3.18"]
56-
assert provider.scanners == ["vuln", "secret"]
56+
assert provider.scanners == ["vuln", "secret", "misconfig"]
5757
assert provider.image_config_scanners == []
5858
assert provider.trivy_severity == []
5959
assert provider.ignore_unfixed is False
@@ -698,8 +698,118 @@ def test_bare_image_name(self):
698698

699699

700700
class TestIsRegistryUrl:
701-
def test_registry_url_with_namespace(self):
702-
assert ImageProvider._is_registry_url("docker.io/andoniaf") is True
701+
def test_bare_ecr_hostname(self):
702+
assert ImageProvider._is_registry_url(
703+
"714274078102.dkr.ecr.eu-west-1.amazonaws.com"
704+
)
705+
706+
def test_bare_hostname_with_port(self):
707+
assert ImageProvider._is_registry_url("myregistry.com:5000")
708+
709+
def test_bare_ghcr(self):
710+
assert ImageProvider._is_registry_url("ghcr.io")
711+
712+
def test_registry_with_namespace_only(self):
713+
"""Registry URL with a single path segment (no tag) is a registry URL."""
714+
assert ImageProvider._is_registry_url("ghcr.io/myorg")
715+
716+
def test_image_reference_not_registry(self):
717+
"""Full image reference with repo and tag is not a registry URL."""
718+
assert not ImageProvider._is_registry_url("ghcr.io/myorg/repo:tag")
719+
720+
def test_simple_image_name(self):
721+
assert not ImageProvider._is_registry_url("alpine:3.18")
722+
723+
def test_bare_image_no_tag(self):
724+
assert not ImageProvider._is_registry_url("nginx")
725+
726+
def test_dockerhub_namespace(self):
727+
assert not ImageProvider._is_registry_url("library/alpine")
728+
729+
730+
class TestTestRegistryConnection:
731+
@patch("prowler.providers.image.image_provider.create_registry_adapter")
732+
def test_registry_connection_success(self, mock_factory):
733+
"""Test that a bare hostname triggers registry catalog test."""
734+
mock_adapter = MagicMock()
735+
mock_adapter.list_repositories.return_value = ["repo1"]
736+
mock_factory.return_value = mock_adapter
737+
738+
result = ImageProvider.test_connection(
739+
image="714274078102.dkr.ecr.eu-west-1.amazonaws.com",
740+
registry_username="user",
741+
registry_password="pass",
742+
)
743+
744+
assert result.is_connected is True
745+
mock_factory.assert_called_once_with(
746+
registry_url="714274078102.dkr.ecr.eu-west-1.amazonaws.com",
747+
username="user",
748+
password="pass",
749+
token=None,
750+
)
751+
mock_adapter.list_repositories.assert_called_once()
752+
753+
@patch("prowler.providers.image.image_provider.create_registry_adapter")
754+
def test_registry_connection_auth_failure(self, mock_factory):
755+
"""Test that 401 from registry adapter returns auth failure."""
756+
mock_adapter = MagicMock()
757+
mock_adapter.list_repositories.side_effect = Exception("401 unauthorized")
758+
mock_factory.return_value = mock_adapter
759+
760+
result = ImageProvider.test_connection(
761+
image="714274078102.dkr.ecr.eu-west-1.amazonaws.com",
762+
)
763+
764+
assert result.is_connected is False
765+
assert "Authentication failed" in result.error
766+
767+
@patch("prowler.providers.image.image_provider.create_registry_adapter")
768+
def test_registry_connection_generic_error(self, mock_factory):
769+
"""Test that a generic error from registry adapter returns error message."""
770+
mock_adapter = MagicMock()
771+
mock_adapter.list_repositories.side_effect = Exception("connection refused")
772+
mock_factory.return_value = mock_adapter
773+
774+
result = ImageProvider.test_connection(
775+
image="myregistry.example.com",
776+
)
777+
778+
assert result.is_connected is False
779+
assert "Failed to connect to registry" in result.error
780+
781+
@patch("prowler.providers.image.image_provider.create_registry_adapter")
782+
def test_image_reference_uses_registry_adapter(self, mock_factory):
783+
"""Test that a full image reference uses registry adapter to verify tag."""
784+
mock_adapter = MagicMock()
785+
mock_adapter.list_tags.return_value = ["3.18", "latest"]
786+
mock_factory.return_value = mock_adapter
787+
788+
result = ImageProvider.test_connection(image="alpine:3.18")
789+
790+
assert result.is_connected is True
791+
mock_adapter.list_tags.assert_called_once()
792+
793+
794+
class TestTrivyAuthIntegration:
795+
@patch("subprocess.run")
796+
def test_run_scan_passes_trivy_env_with_credentials(self, mock_subprocess):
797+
"""Test that run_scan() passes TRIVY_USERNAME/PASSWORD via env when credentials are set."""
798+
mock_subprocess.return_value = MagicMock(
799+
returncode=0, stdout=get_sample_trivy_json_output(), stderr=""
800+
)
801+
provider = _make_provider(
802+
images=["ghcr.io/user/image:tag"],
803+
registry_username="myuser",
804+
registry_password="mypass",
805+
)
806+
807+
list(provider.run_scan())
808+
809+
call_kwargs = mock_subprocess.call_args
810+
env = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env")
811+
assert env["TRIVY_USERNAME"] == "myuser"
812+
assert env["TRIVY_PASSWORD"] == "mypass"
703813

704814
def test_registry_url_ghcr(self):
705815
assert ImageProvider._is_registry_url("ghcr.io/org") is True
@@ -734,6 +844,16 @@ def test_cleanup_idempotent(self):
734844
provider.cleanup()
735845
provider.cleanup()
736846

847+
def test_cleanup_removes_trivy_cache_dir(self):
848+
"""Test that cleanup removes the temporary Trivy cache directory."""
849+
provider = _make_provider()
850+
cache_dir = provider._trivy_cache_dir
851+
assert os.path.isdir(cache_dir)
852+
853+
provider.cleanup()
854+
855+
assert not os.path.isdir(cache_dir)
856+
737857

738858
class TestImageProviderInputValidation:
739859
def test_invalid_timeout_format_raises_error(self):
@@ -804,6 +924,22 @@ def test_invalid_image_config_scanner_raises_error(self):
804924
with pytest.raises(ImageInvalidConfigScannerError):
805925
_make_provider(image_config_scanners=["misconfig", "vuln"])
806926

927+
@patch("subprocess.run")
928+
def test_trivy_command_includes_cache_dir(self, mock_subprocess):
929+
"""Test that Trivy command includes --cache-dir for cache isolation."""
930+
provider = _make_provider()
931+
mock_subprocess.return_value = MagicMock(
932+
returncode=0, stdout=get_empty_trivy_output(), stderr=""
933+
)
934+
935+
for _ in provider._scan_single_image("alpine:3.18"):
936+
pass
937+
938+
call_args = mock_subprocess.call_args[0][0]
939+
assert "--cache-dir" in call_args
940+
idx = call_args.index("--cache-dir")
941+
assert call_args[idx + 1] == provider._trivy_cache_dir
942+
807943
@patch("subprocess.run")
808944
def test_trivy_command_includes_image_config_scanners(self, mock_subprocess):
809945
"""Test that Trivy command includes --image-config-scanners when set."""

ui/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
66

77
### 🚀 Added
88

9+
- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167)
910
- Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317)
1011
- Events tab in Findings and Resource detail cards showing an AWS CloudTrail timeline with expandable event rows, actor info, request/response JSON payloads, and error details [(#10320)](https://github.com/prowler-cloud/prowler/pull/10320)
1112

ui/app/(prowler)/_overview/_components/accounts-selector.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
GCPProviderBadge,
1212
GitHubProviderBadge,
1313
IacProviderBadge,
14+
ImageProviderBadge,
1415
KS8ProviderBadge,
1516
M365ProviderBadge,
1617
MongoDBAtlasProviderBadge,
@@ -35,6 +36,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
3536
m365: <M365ProviderBadge width={18} height={18} />,
3637
github: <GitHubProviderBadge width={18} height={18} />,
3738
iac: <IacProviderBadge width={18} height={18} />,
39+
image: <ImageProviderBadge width={18} height={18} />,
3840
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
3941
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
4042
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,

0 commit comments

Comments
 (0)