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>
This commit is contained in:
Andoni Alonso
2026-03-17 10:53:37 +01:00
committed by GitHub
parent 887a20f06e
commit 451071d694
25 changed files with 551 additions and 61 deletions

View File

@@ -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)

View File

@@ -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]}",
)

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ import {
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
@@ -35,6 +36,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
m365: <M365ProviderBadge width={18} height={18} />,
github: <GitHubProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
image: <ImageProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,

View File

@@ -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<IconProps> }
{ label: string; icon: ComponentType<IconProps> }
> = {
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,

View File

@@ -6,6 +6,7 @@ import {
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
@@ -85,6 +86,15 @@ export const CustomProviderInputIac = () => {
);
};
export const CustomProviderInputImage = () => {
return (
<div className="flex items-center gap-x-2">
<ImageProviderBadge width={25} height={25} />
<p className="text-sm">Container Registry</p>
</div>
);
};
export const CustomProviderInputOracleCloud = () => {
return (
<div className="flex items-center gap-x-2">

View File

@@ -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,

View File

@@ -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({
</p>
<div className="flex flex-wrap gap-4">
{zeroDataProviders.map((provider) => {
const IconComponent = PROVIDER_ICONS[provider.displayName];
const IconComponent =
PROVIDER_BADGE_BY_NAME[provider.displayName];
return (
<div
key={provider.id}

View File

@@ -0,0 +1,36 @@
import { FC } from "react";
import { IconSvgProps } from "@/types";
export const ImageProviderBadge: FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<rect width="256" height="256" fill="#1c1917" rx="60" />
<g
transform="translate(20, 20) scale(9)"
fill="none"
stroke="#fff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12.89 1.45L21 5.75V18.25L12.89 22.55C12.33 22.84 11.67 22.84 11.11 22.55L3 18.25V5.75L11.11 1.45C11.67 1.16 12.33 1.16 12.89 1.45Z" />
<path d="M3.5 6L12 10.5L20.5 6" />
<path d="M12 22.5V10.5" />
</g>
</svg>
);

View File

@@ -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<string, FC<IconSvgProps>> = {
export const PROVIDER_BADGE_BY_NAME: Record<string, FC<IconSvgProps>> = {
AWS: AWSProviderBadge,
Azure: AzureProviderBadge,
"Google Cloud": GCPProviderBadge,
@@ -39,6 +41,7 @@ export const PROVIDER_ICONS: Record<string, FC<IconSvgProps>> = {
"Microsoft 365": M365ProviderBadge,
GitHub: GitHubProviderBadge,
"Infrastructure as Code": IacProviderBadge,
"Container Registry": ImageProviderBadge,
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
"MongoDB Atlas": MongoDBAtlasProviderBadge,
"Alibaba Cloud": AlibabaCloudProviderBadge,

View File

@@ -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",

View File

@@ -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<IacCredentials>}
/>
)}
{providerType === "image" && (
<ImageCredentialsForm
control={form.control as unknown as Control<ImageCredentials>}
/>
)}
{providerType === "oraclecloud" && (
<OracleCloudCredentialsForm
control={form.control as unknown as Control<OCICredentials>}

View File

@@ -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",

View File

@@ -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<ImageCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via Registry Credentials
</div>
<div className="text-default-500 text-sm">
Provide registry credentials to authenticate with your container
registry (all fields are optional).
</div>
</div>
<WizardInputField
control={control}
name={ProviderCredentialFields.REGISTRY_USERNAME}
label="Registry Username (Optional)"
labelPlacement="inside"
placeholder="Username for registry authentication"
variant="bordered"
type="text"
isRequired={false}
/>
<WizardInputField
control={control}
name={ProviderCredentialFields.REGISTRY_PASSWORD}
label="Registry Password (Optional)"
labelPlacement="inside"
placeholder="Password for registry authentication"
variant="bordered"
type="password"
isRequired={false}
/>
<WizardInputField
control={control}
name={ProviderCredentialFields.REGISTRY_TOKEN}
label="Registry Token (Optional)"
labelPlacement="inside"
placeholder="Token for registry authentication"
variant="bordered"
type="password"
isRequired={false}
/>
<div className="flex flex-col pt-2">
<div className="text-md text-default-foreground leading-9 font-bold">
Scan Scope
</div>
<div className="text-default-500 text-sm">
Limit which repositories and tags are scanned using regex patterns.
</div>
</div>
<WizardInputField
control={control}
name={ProviderCredentialFields.IMAGE_FILTER}
label="Image Filter (Optional)"
labelPlacement="inside"
placeholder="e.g. ^prod/.*"
variant="bordered"
type="text"
isRequired={false}
/>
<WizardInputField
control={control}
name={ProviderCredentialFields.TAG_FILTER}
label="Tag Filter (Optional)"
labelPlacement="inside"
placeholder="e.g. ^(latest|v\d+\.\d+\.\d+)$"
variant="bordered"
type="text"
isRequired={false}
/>
</>
);
};

View File

@@ -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";

View File

@@ -6,6 +6,7 @@ import {
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
@@ -30,6 +31,8 @@ export const getProviderLogo = (provider: ProviderType) => {
return <GitHubProviderBadge width={35} height={35} />;
case "iac":
return <IacProviderBadge width={35} height={35} />;
case "image":
return <ImageProviderBadge width={35} height={35} />;
case "oraclecloud":
return <OracleCloudProviderBadge width={35} height={35} />;
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":

View File

@@ -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;
}

View File

@@ -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?",

View File

@@ -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),

View File

@@ -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",

View File

@@ -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

View File

@@ -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<string, string | undefined>, 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") {

View File

@@ -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<ProviderType, string> = {
mongodbatlas: "MongoDB Atlas",
github: "GitHub",
iac: "Infrastructure as Code",
image: "Container Registry",
oraclecloud: "Oracle Cloud Infrastructure",
alibabacloud: "Alibaba Cloud",
cloudflare: "Cloudflare",