mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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]}",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
36
ui/components/icons/providers-badge/image-provider-badge.tsx
Normal file
36
ui/components/icons/providers-badge/image-provider-badge.tsx
Normal 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>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user