From 451071d6949efcaf49d845eec5159591ee952954 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:53:37 +0100 Subject: [PATCH] feat(image): add image provider to UI (#10167) Co-authored-by: alejandrobailo Co-authored-by: Claude Opus 4.6 Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> --- prowler/CHANGELOG.md | 1 + prowler/providers/image/image_provider.py | 96 +++++++++--- .../image/lib/arguments/arguments.py | 4 +- tests/providers/image/image_provider_test.py | 142 +++++++++++++++++- ui/CHANGELOG.md | 1 + .../_components/accounts-selector.tsx | 2 + .../_components/provider-type-selector.tsx | 13 +- .../filters/custom-provider-inputs.tsx | 10 ++ .../findings/table/provider-icon-cell.tsx | 2 + ui/components/graphs/sankey-chart.tsx | 7 +- .../providers-badge/image-provider-badge.tsx | 36 +++++ ui/components/icons/providers-badge/index.ts | 5 +- .../providers/radio-group-provider.tsx | 6 + .../workflow/forms/base-credentials-form.tsx | 7 + .../workflow/forms/connect-account-form.tsx | 5 + .../image-credentials-form.tsx | 84 +++++++++++ .../workflow/forms/via-credentials/index.ts | 1 + .../ui/entities/get-provider-logo.tsx | 5 + ui/hooks/use-credentials-form.ts | 9 ++ ui/lib/external-urls.ts | 5 + .../build-crendentials.ts | 30 ++++ .../provider-credential-fields.ts | 12 ++ ui/types/components.ts | 10 ++ ui/types/formSchemas.ts | 117 +++++++++++---- ui/types/providers.ts | 2 + 25 files changed, 551 insertions(+), 61 deletions(-) create mode 100644 ui/components/icons/providers-badge/image-provider-badge.tsx create mode 100644 ui/components/providers/workflow/forms/via-credentials/image-credentials-form.tsx diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3e7efa203a..1cc4cbc410 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `misconfig` scanner as default for Image provider scans [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) - `entra_conditional_access_policy_device_code_flow_blocked` check for M365 provider [(#10218)](https://github.com/prowler-cloud/prowler/pull/10218) - CheckMetadata Pydantic validators [(#8584)](https://github.com/prowler-cloud/prowler/pull/8583) - `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) diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 9817656dd2..4beb5eca4e 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -5,6 +5,7 @@ import os import re import subprocess import sys +import tempfile from typing import Generator from alive_progress import alive_bar @@ -88,7 +89,9 @@ class ImageProvider(Provider): self.images = images if images is not None else [] self.image_list_file = image_list_file - self.scanners = scanners if scanners is not None else ["vuln", "secret"] + self.scanners = ( + scanners if scanners is not None else ["vuln", "secret", "misconfig"] + ) self.image_config_scanners = ( image_config_scanners if image_config_scanners is not None else [] ) @@ -100,6 +103,10 @@ class ImageProvider(Provider): self._session = None self._identity = "prowler" self._listing_only = False + self._trivy_cache_dir_obj = tempfile.TemporaryDirectory( + prefix="prowler-trivy-cache-" + ) + self._trivy_cache_dir = self._trivy_cache_dir_obj.name # Registry authentication (follows IaC pattern: explicit params, env vars internal) self.registry_username = registry_username or os.environ.get( @@ -329,9 +336,15 @@ class ImageProvider(Provider): def _is_registry_url(image_uid: str) -> bool: """Determine whether an image UID is a registry URL (namespace only). - A registry URL like ``docker.io/andoniaf`` has a registry host but - the remaining part contains no ``/`` (no repo) and no ``:`` (no tag). + Bare hostnames like "714274078102.dkr.ecr.eu-west-1.amazonaws.com" + or "myregistry.com:5000" are registry URLs (dots in host, no slash). + Image references like "alpine:3.18" or "nginx" are not. """ + if "/" not in image_uid: + host_part = image_uid.split(":")[0] + if "." in host_part: + return True + registry_host = ImageProvider._extract_registry(image_uid) if not registry_host: return False @@ -340,6 +353,8 @@ class ImageProvider(Provider): def cleanup(self) -> None: """Clean up any resources after scanning.""" + if hasattr(self, "_trivy_cache_dir_obj"): + self._trivy_cache_dir_obj.cleanup() def _process_finding( self, @@ -540,6 +555,8 @@ class ImageProvider(Provider): trivy_command = [ "trivy", "image", + "--cache-dir", + self._trivy_cache_dir, "--format", "json", "--scanners", @@ -928,6 +945,9 @@ class ImageProvider(Provider): Uses registry HTTP APIs directly instead of Trivy to avoid false failures caused by Trivy DB download issues. + For bare registry hostnames (e.g. ECR URLs passed by the API as provider_uid), + uses the OCI catalog endpoint instead of trivy image. + Args: image: Container image or registry URL to test raise_on_exception: Whether to raise exceptions @@ -946,32 +966,34 @@ class ImageProvider(Provider): if not image: return Connection(is_connected=False, error="Image name is required") + # Registry URL (bare hostname) → test via OCI catalog if ImageProvider._is_registry_url(image): - # Registry enumeration mode — test by listing repositories - adapter = create_registry_adapter( + return ImageProvider._test_registry_connection( registry_url=image, - username=registry_username, - password=registry_password, - token=registry_token, + registry_username=registry_username, + registry_password=registry_password, + registry_token=registry_token, ) - adapter.list_repositories() - return Connection(is_connected=True) - # Image reference mode — verify the specific tag exists + # Image reference → verify tag exists via registry API registry_host = ImageProvider._extract_registry(image) - repo_and_tag = image[len(registry_host) + 1 :] if registry_host else image - if ":" in repo_and_tag: - repository, tag = repo_and_tag.rsplit(":", 1) - else: - repository = repo_and_tag - tag = "latest" - - is_dockerhub = not registry_host or registry_host in ( + is_dockerhub = registry_host is None or registry_host in ( "docker.io", "registry-1.docker.io", ) - # Docker Hub official images use "library/" prefix + # Parse repository and tag from the image reference + ref = image.rsplit("@", 1)[0] if "@" in image else image + last_segment = ref.split("/")[-1] + if ":" in last_segment: + tag = last_segment.split(":")[-1] + base = ref[: -(len(tag) + 1)] + else: + tag = "latest" + base = ref + + repository = base[len(registry_host) + 1 :] if registry_host else base + if is_dockerhub and "/" not in repository: repository = f"library/{repository}" @@ -1013,3 +1035,37 @@ class ImageProvider(Provider): is_connected=False, error=f"Unexpected error: {str(error)}", ) + + @staticmethod + def _test_registry_connection( + registry_url: str, + registry_username: str | None = None, + registry_password: str | None = None, + registry_token: str | None = None, + ) -> "Connection": + """Test connection to a registry URL by listing repositories via OCI catalog.""" + try: + adapter = create_registry_adapter( + registry_url=registry_url, + username=registry_username, + password=registry_password, + token=registry_token, + ) + adapter.list_repositories() + return Connection(is_connected=True) + except Exception as error: + error_str = str(error).lower() + if "401" in error_str or "unauthorized" in error_str: + return Connection( + is_connected=False, + error="Authentication failed. Check registry credentials.", + ) + elif "404" in error_str or "not found" in error_str: + return Connection( + is_connected=False, + error="Registry catalog not found.", + ) + return Connection( + is_connected=False, + error=f"Failed to connect to registry: {str(error)[:200]}", + ) diff --git a/prowler/providers/image/lib/arguments/arguments.py b/prowler/providers/image/lib/arguments/arguments.py index 3dfe9cf92b..529061befc 100644 --- a/prowler/providers/image/lib/arguments/arguments.py +++ b/prowler/providers/image/lib/arguments/arguments.py @@ -50,9 +50,9 @@ def init_parser(self): "--scanner", dest="scanners", nargs="+", - default=["vuln", "secret"], + default=["vuln", "secret", "misconfig"], choices=SCANNERS_CHOICES, - help="Trivy scanners to use. Default: vuln, secret. Available: vuln, secret, misconfig, license", + help="Trivy scanners to use. Default: vuln, secret, misconfig. Available: vuln, secret, misconfig, license", ) scan_config_group.add_argument( diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index b25e9edfc2..ab9358b002 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -53,7 +53,7 @@ class TestImageProvider: assert provider._type == "image" assert provider.type == "image" assert provider.images == ["alpine:3.18"] - assert provider.scanners == ["vuln", "secret"] + assert provider.scanners == ["vuln", "secret", "misconfig"] assert provider.image_config_scanners == [] assert provider.trivy_severity == [] assert provider.ignore_unfixed is False @@ -698,8 +698,118 @@ class TestExtractRegistry: class TestIsRegistryUrl: - def test_registry_url_with_namespace(self): - assert ImageProvider._is_registry_url("docker.io/andoniaf") is True + 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") + + +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 @@ -734,6 +844,16 @@ class TestCleanup: 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): @@ -804,6 +924,22 @@ class TestImageProviderInputValidation: 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.""" diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9842b76870..d124555be7 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) - 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) - 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) diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 0e3845787e..0be0e1dd20 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -11,6 +11,7 @@ import { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -35,6 +36,7 @@ const PROVIDER_ICON: Record = { m365: , github: , iac: , + image: , oraclecloud: , mongodbatlas: , alibabacloud: , diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 881bc54969..bf7adf87cd 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,7 +1,7 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { lazy, Suspense } from "react"; +import { type ComponentType, lazy, Suspense } from "react"; import { MultiSelect, @@ -48,6 +48,11 @@ const IacProviderBadge = lazy(() => default: m.IacProviderBadge, })), ); +const ImageProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.ImageProviderBadge, + })), +); const OracleCloudProviderBadge = lazy(() => import("@/components/icons/providers-badge").then((m) => ({ default: m.OracleCloudProviderBadge, @@ -82,7 +87,7 @@ const IconPlaceholder = ({ width, height }: IconProps) => ( const PROVIDER_DATA: Record< ProviderType, - { label: string; icon: React.ComponentType } + { label: string; icon: ComponentType } > = { aws: { label: "Amazon Web Services", @@ -112,6 +117,10 @@ const PROVIDER_DATA: Record< label: "Infrastructure as Code", icon: IacProviderBadge, }, + image: { + label: "Container Registry", + icon: ImageProviderBadge, + }, oraclecloud: { label: "Oracle Cloud Infrastructure", icon: OracleCloudProviderBadge, diff --git a/ui/components/filters/custom-provider-inputs.tsx b/ui/components/filters/custom-provider-inputs.tsx index 46937401dc..3e435b3fd7 100644 --- a/ui/components/filters/custom-provider-inputs.tsx +++ b/ui/components/filters/custom-provider-inputs.tsx @@ -6,6 +6,7 @@ import { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -85,6 +86,15 @@ export const CustomProviderInputIac = () => { ); }; +export const CustomProviderInputImage = () => { + return ( +
+ +

Container Registry

+
+ ); +}; + export const CustomProviderInputOracleCloud = () => { return (
diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 613d236985..f368b776cd 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -6,6 +6,7 @@ import { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -22,6 +23,7 @@ export const PROVIDER_ICONS = { m365: M365ProviderBadge, github: GitHubProviderBadge, iac: IacProviderBadge, + image: ImageProviderBadge, oraclecloud: OracleCloudProviderBadge, mongodbatlas: MongoDBAtlasProviderBadge, alibabacloud: AlibabaCloudProviderBadge, diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index fe7708e7a1..fc7a913444 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; -import { PROVIDER_ICONS } from "@/components/icons/providers-badge"; +import { PROVIDER_BADGE_BY_NAME } from "@/components/icons/providers-badge"; import { initializeChartColors } from "@/lib/charts/colors"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; @@ -209,7 +209,7 @@ const CustomNode = ({ } }; - const IconComponent = PROVIDER_ICONS[nodeName]; + const IconComponent = PROVIDER_BADGE_BY_NAME[nodeName]; const hasIcon = IconComponent !== undefined; const iconSize = 24; const iconGap = 8; @@ -620,7 +620,8 @@ export function SankeyChart({

{zeroDataProviders.map((provider) => { - const IconComponent = PROVIDER_ICONS[provider.displayName]; + const IconComponent = + PROVIDER_BADGE_BY_NAME[provider.displayName]; return (
= ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts index 9b8b5e7a82..5627352ab0 100644 --- a/ui/components/icons/providers-badge/index.ts +++ b/ui/components/icons/providers-badge/index.ts @@ -9,6 +9,7 @@ import { CloudflareProviderBadge } from "./cloudflare-provider-badge"; import { GCPProviderBadge } from "./gcp-provider-badge"; import { GitHubProviderBadge } from "./github-provider-badge"; import { IacProviderBadge } from "./iac-provider-badge"; +import { ImageProviderBadge } from "./image-provider-badge"; import { KS8ProviderBadge } from "./ks8-provider-badge"; import { M365ProviderBadge } from "./m365-provider-badge"; import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge"; @@ -23,6 +24,7 @@ export { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -31,7 +33,7 @@ export { }; // Map provider display names to their icon components -export const PROVIDER_ICONS: Record> = { +export const PROVIDER_BADGE_BY_NAME: Record> = { AWS: AWSProviderBadge, Azure: AzureProviderBadge, "Google Cloud": GCPProviderBadge, @@ -39,6 +41,7 @@ export const PROVIDER_ICONS: Record> = { "Microsoft 365": M365ProviderBadge, GitHub: GitHubProviderBadge, "Infrastructure as Code": IacProviderBadge, + "Container Registry": ImageProviderBadge, "Oracle Cloud Infrastructure": OracleCloudProviderBadge, "MongoDB Atlas": MongoDBAtlasProviderBadge, "Alibaba Cloud": AlibabaCloudProviderBadge, diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index b071062e94..a179772e20 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -16,6 +16,7 @@ import { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -65,6 +66,11 @@ const PROVIDERS = [ label: "Infrastructure as Code", badge: IacProviderBadge, }, + { + value: "image", + label: "Container Registry", + badge: ImageProviderBadge, + }, { value: "oraclecloud", label: "Oracle Cloud Infrastructure", diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index 959749b8b7..aa61d07479 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -23,6 +23,7 @@ import { GCPDefaultCredentials, GCPServiceAccountKey, IacCredentials, + ImageCredentials, KubernetesCredentials, M365CertificateCredentials, M365ClientSecretCredentials, @@ -52,6 +53,7 @@ import { import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form"; import { IacCredentialsForm } from "./via-credentials/iac-credentials-form"; +import { ImageCredentialsForm } from "./via-credentials/image-credentials-form"; import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; import { MongoDBAtlasCredentialsForm } from "./via-credentials/mongodbatlas-credentials-form"; import { OpenStackCredentialsForm } from "./via-credentials/openstack-credentials-form"; @@ -211,6 +213,11 @@ export const BaseCredentialsForm = ({ control={form.control as unknown as Control} /> )} + {providerType === "image" && ( + } + /> + )} {providerType === "oraclecloud" && ( } diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index 2b62a79459..0c0687081e 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -81,6 +81,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => { label: "Repository URL", placeholder: "e.g. https://github.com/user/repo", }; + case "image": + return { + label: "Registry URL", + placeholder: "e.g. 123456789012.dkr.ecr.us-east-1.amazonaws.com", + }; case "oraclecloud": return { label: "Tenancy OCID", diff --git a/ui/components/providers/workflow/forms/via-credentials/image-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/image-credentials-form.tsx new file mode 100644 index 0000000000..b071b1c013 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/image-credentials-form.tsx @@ -0,0 +1,84 @@ +import { Control } from "react-hook-form"; + +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { ImageCredentials } from "@/types"; + +export const ImageCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Registry Credentials +
+
+ Provide registry credentials to authenticate with your container + registry (all fields are optional). +
+
+ + + + +
+
+ Scan Scope +
+
+ Limit which repositories and tags are scanned using regex patterns. +
+
+ + + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/index.ts b/ui/components/providers/workflow/forms/via-credentials/index.ts index 2b44d85485..4f5b55846a 100644 --- a/ui/components/providers/workflow/forms/via-credentials/index.ts +++ b/ui/components/providers/workflow/forms/via-credentials/index.ts @@ -1,6 +1,7 @@ export * from "./azure-credentials-form"; export * from "./github-credentials-form"; export * from "./iac-credentials-form"; +export * from "./image-credentials-form"; export * from "./k8s-credentials-form"; export * from "./mongodbatlas-credentials-form"; export * from "./openstack-credentials-form"; diff --git a/ui/components/ui/entities/get-provider-logo.tsx b/ui/components/ui/entities/get-provider-logo.tsx index 7ac437a4af..1fb8a5ae41 100644 --- a/ui/components/ui/entities/get-provider-logo.tsx +++ b/ui/components/ui/entities/get-provider-logo.tsx @@ -6,6 +6,7 @@ import { GCPProviderBadge, GitHubProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, @@ -30,6 +31,8 @@ export const getProviderLogo = (provider: ProviderType) => { return ; case "iac": return ; + case "image": + return ; case "oraclecloud": return ; case "mongodbatlas": @@ -61,6 +64,8 @@ export const getProviderName = (provider: ProviderType): string => { return "GitHub"; case "iac": return "Infrastructure as Code"; + case "image": + return "Container Registry"; case "oraclecloud": return "Oracle Cloud Infrastructure"; case "mongodbatlas": diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts index 10008c70d5..020dc7a6b2 100644 --- a/ui/hooks/use-credentials-form.ts +++ b/ui/hooks/use-credentials-form.ts @@ -226,6 +226,15 @@ export const useCredentialsForm = ({ [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: "", [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: "", }; + case "image": + return { + ...baseDefaults, + [ProviderCredentialFields.REGISTRY_USERNAME]: "", + [ProviderCredentialFields.REGISTRY_PASSWORD]: "", + [ProviderCredentialFields.REGISTRY_TOKEN]: "", + [ProviderCredentialFields.IMAGE_FILTER]: "", + [ProviderCredentialFields.TAG_FILTER]: "", + }; default: return baseDefaults; } diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 654bf27b43..023b90df1e 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -57,6 +57,11 @@ export const getProviderHelpText = (provider: string) => { text: "Need help scanning your Infrastructure as Code repository?", link: "https://goto.prowler.com/provider-iac", }; + case "image": + return { + text: "Need help scanning your container registry?", + link: "https://goto.prowler.com/provider-image", + }; case "oraclecloud": return { text: "Need help connecting your Oracle Cloud account?", diff --git a/ui/lib/provider-credentials/build-crendentials.ts b/ui/lib/provider-credentials/build-crendentials.ts index fb0e29a18b..9238a9d9a6 100644 --- a/ui/lib/provider-credentials/build-crendentials.ts +++ b/ui/lib/provider-credentials/build-crendentials.ts @@ -278,6 +278,32 @@ export const buildIacSecret = (formData: FormData) => { return filterEmptyValues(secret); }; +export const buildImageSecret = (formData: FormData) => { + const secret = { + [ProviderCredentialFields.REGISTRY_USERNAME]: getFormValue( + formData, + ProviderCredentialFields.REGISTRY_USERNAME, + ), + [ProviderCredentialFields.REGISTRY_PASSWORD]: getFormValue( + formData, + ProviderCredentialFields.REGISTRY_PASSWORD, + ), + [ProviderCredentialFields.REGISTRY_TOKEN]: getFormValue( + formData, + ProviderCredentialFields.REGISTRY_TOKEN, + ), + [ProviderCredentialFields.IMAGE_FILTER]: getFormValue( + formData, + ProviderCredentialFields.IMAGE_FILTER, + ), + [ProviderCredentialFields.TAG_FILTER]: getFormValue( + formData, + ProviderCredentialFields.TAG_FILTER, + ), + }; + return filterEmptyValues(secret); +}; + /** * Utility function to safely encode a string to base64 * Handles UTF-8 characters properly without using deprecated APIs @@ -426,6 +452,10 @@ export const buildSecretConfig = ( secretType: "static", secret: buildIacSecret(formData), }), + image: () => ({ + secretType: "static", + secret: buildImageSecret(formData), + }), oraclecloud: () => ({ secretType: "static", secret: buildOracleCloudSecret(formData, providerUid), diff --git a/ui/lib/provider-credentials/provider-credential-fields.ts b/ui/lib/provider-credentials/provider-credential-fields.ts index 6ed6aedfdd..c5773233e1 100644 --- a/ui/lib/provider-credentials/provider-credential-fields.ts +++ b/ui/lib/provider-credentials/provider-credential-fields.ts @@ -53,6 +53,13 @@ export const ProviderCredentialFields = { REPOSITORY_URL: "repository_url", ACCESS_TOKEN: "access_token", + // Image (Container Registry) fields + REGISTRY_USERNAME: "registry_username", + REGISTRY_PASSWORD: "registry_password", + REGISTRY_TOKEN: "registry_token", + IMAGE_FILTER: "image_filter", + TAG_FILTER: "tag_filter", + // OCI fields OCI_USER: "user", OCI_FINGERPRINT: "fingerprint", @@ -106,6 +113,11 @@ export const ErrorPointers = { GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content", REPOSITORY_URL: "/data/attributes/secret/repository_url", ACCESS_TOKEN: "/data/attributes/secret/access_token", + REGISTRY_USERNAME: "/data/attributes/secret/registry_username", + REGISTRY_PASSWORD: "/data/attributes/secret/registry_password", + REGISTRY_TOKEN: "/data/attributes/secret/registry_token", + IMAGE_FILTER: "/data/attributes/secret/image_filter", + TAG_FILTER: "/data/attributes/secret/tag_filter", CERTIFICATE_CONTENT: "/data/attributes/secret/certificate_content", OCI_USER: "/data/attributes/secret/user", OCI_FINGERPRINT: "/data/attributes/secret/fingerprint", diff --git a/ui/types/components.ts b/ui/types/components.ts index cf74cc7970..96fbce5083 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -304,6 +304,15 @@ export type IacCredentials = { [ProviderCredentialFields.PROVIDER_ID]: string; }; +export type ImageCredentials = { + [ProviderCredentialFields.REGISTRY_USERNAME]?: string; + [ProviderCredentialFields.REGISTRY_PASSWORD]?: string; + [ProviderCredentialFields.REGISTRY_TOKEN]?: string; + [ProviderCredentialFields.IMAGE_FILTER]?: string; + [ProviderCredentialFields.TAG_FILTER]?: string; + [ProviderCredentialFields.PROVIDER_ID]: string; +}; + export type OCICredentials = { [ProviderCredentialFields.OCI_USER]: string; [ProviderCredentialFields.OCI_FINGERPRINT]: string; @@ -363,6 +372,7 @@ export type CredentialsFormSchema = | GCPServiceAccountKey | KubernetesCredentials | IacCredentials + | ImageCredentials | M365Credentials | OCICredentials | MongoDBAtlasCredentials diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index 9c4cc58f41..c7044c782c 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -115,6 +115,11 @@ export const addProviderFormSchema = z [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string().trim().min(1, "Provider ID is required"), }), + z.object({ + providerType: z.literal("image"), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), + providerUid: z.string().trim().min(1, "Provider ID is required"), + }), z.object({ providerType: z.literal("oraclecloud"), [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), @@ -269,44 +274,63 @@ export const addCredentialsFormSchema = ( .string() .min(1, "Access Key Secret is required"), } - : providerType === "cloudflare" + : providerType === "image" ? { - [ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: - z.string().optional(), - [ProviderCredentialFields.CLOUDFLARE_API_KEY]: z + [ProviderCredentialFields.REGISTRY_USERNAME]: z + .string() + .optional(), + [ProviderCredentialFields.REGISTRY_PASSWORD]: z + .string() + .optional(), + [ProviderCredentialFields.REGISTRY_TOKEN]: z + .string() + .optional(), + [ProviderCredentialFields.IMAGE_FILTER]: z + .string() + .optional(), + [ProviderCredentialFields.TAG_FILTER]: z .string() .optional(), - [ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: - z - .string() - .superRefine((val, ctx) => { - if (val && val.trim() !== "") { - const emailRegex = - /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(val)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "Please enter a valid email address", - }); - } - } - }) - .optional(), } - : providerType === "openstack" + : providerType === "cloudflare" ? { - [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: + [ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: + z.string().optional(), + [ProviderCredentialFields.CLOUDFLARE_API_KEY]: + z.string().optional(), + [ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: z .string() - .min( - 1, - "Clouds YAML content is required", - ), - [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: - z.string().min(1, "Cloud name is required"), + .superRefine((val, ctx) => { + if (val && val.trim() !== "") { + const emailRegex = + /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Please enter a valid email address", + }); + } + } + }) + .optional(), } - : {}), + : providerType === "openstack" + ? { + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: + z + .string() + .min( + 1, + "Clouds YAML content is required", + ), + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: + z + .string() + .min(1, "Cloud name is required"), + } + : {}), }) .superRefine((data: Record, ctx) => { if (providerType === "m365") { @@ -369,6 +393,39 @@ export const addCredentialsFormSchema = ( } } + if (providerType === "image") { + const token = data[ProviderCredentialFields.REGISTRY_TOKEN]; + const username = data[ProviderCredentialFields.REGISTRY_USERNAME]; + const password = data[ProviderCredentialFields.REGISTRY_PASSWORD]; + + // When a token is provided, username/password validation is skipped (matches API behavior) + if (!token || token.trim() === "") { + if ( + username && + username.trim() !== "" && + (!password || password.trim() === "") + ) { + ctx.addIssue({ + code: "custom", + message: + "Registry Password is required when providing a username", + path: [ProviderCredentialFields.REGISTRY_PASSWORD], + }); + } + if ( + password && + password.trim() !== "" && + (!username || username.trim() === "") + ) { + ctx.addIssue({ + code: "custom", + message: + "Registry Username is required when providing a password", + path: [ProviderCredentialFields.REGISTRY_USERNAME], + }); + } + } + } if (providerType === "cloudflare") { // For Cloudflare, validation depends on the 'via' parameter if (via === "api_token") { diff --git a/ui/types/providers.ts b/ui/types/providers.ts index 6e6a2c79c6..066bea0414 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -7,6 +7,7 @@ export const PROVIDER_TYPES = [ "mongodbatlas", "github", "iac", + "image", "oraclecloud", "alibabacloud", "cloudflare", @@ -24,6 +25,7 @@ export const PROVIDER_DISPLAY_NAMES: Record = { mongodbatlas: "MongoDB Atlas", github: "GitHub", iac: "Infrastructure as Code", + image: "Container Registry", oraclecloud: "Oracle Cloud Infrastructure", alibabacloud: "Alibaba Cloud", cloudflare: "Cloudflare",