diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8b36cb94a1..5fcf573805 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.15.0] (Prowler UNRELEASED) ### Added +- IaC (Infrastructure as Code) provider support for remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) - Extend `GET /api/v1/providers` with provider-type filters and optional pagination disable to support the new Overview filters [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975) - New endpoint to retrieve the number of providers grouped by provider type [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975) - Support for configuring multiple LLM providers [(#8772)](https://github.com/prowler-cloud/prowler/pull/8772) diff --git a/api/Dockerfile b/api/Dockerfile index a3cfb21782..2d7883a957 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,6 +5,9 @@ LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} +ARG TRIVY_VERSION=0.66.0 +ENV TRIVY_VERSION=${TRIVY_VERSION} + # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -36,6 +39,24 @@ RUN ARCH=$(uname -m) && \ ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh && \ rm /tmp/powershell.tar.gz +# Install Trivy for IaC scanning +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + TRIVY_ARCH="Linux-64bit" ; \ + elif [ "$ARCH" = "aarch64" ]; then \ + TRIVY_ARCH="Linux-ARM64" ; \ + else \ + echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \ + fi && \ + wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \ + tar zxf /tmp/trivy.tar.gz -C /tmp && \ + mv /tmp/trivy /usr/local/bin/trivy && \ + chmod +x /usr/local/bin/trivy && \ + rm /tmp/trivy.tar.gz && \ + # Create trivy cache directory with proper permissions + mkdir -p /tmp/.cache/trivy && \ + chmod 777 /tmp/.cache/trivy + # Add prowler user RUN addgroup --gid 1000 prowler && \ adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler diff --git a/api/src/backend/api/migrations/0054_iac_provider.py b/api/src/backend/api/migrations/0054_iac_provider.py new file mode 100644 index 0000000000..3ba456b0cf --- /dev/null +++ b/api/src/backend/api/migrations/0054_iac_provider.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.10 on 2025-09-09 09:25 + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0053_lighthouse_bedrock_openai_compatible"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("oci", "Oracle Cloud Infrastructure"), + ("iac", "IaC"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'iac';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 3a0f1e9df2..43b6d7ae5c 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -284,6 +284,7 @@ class ProviderChoices(models.TextChoices): KUBERNETES = "kubernetes", _("Kubernetes") M365 = "m365", _("M365") GITHUB = "github", _("GitHub") + IAC = "iac", _("IaC") OCI = "oci", _("Oracle Cloud Infrastructure") @staticmethod @@ -355,6 +356,19 @@ def validate_github_uid(value): pointer="/data/attributes/uid", ) + @staticmethod + def validate_iac_uid(value): + # Validate that it's a valid repository URL (git URL format) + if not re.match( + r"^(https?://|git@|ssh://)[^\s/]+[^\s]*\.git$|^(https?://)[^\s/]+[^\s]*$", + value, + ): + raise ModelValidationError( + detail="IaC provider ID must be a valid repository URL (e.g., https://github.com/user/repo or https://github.com/user/repo.git).", + code="iac-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_oci_uid(value): if not re.match( diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 958e176472..d40a46f66e 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -875,6 +875,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -885,6 +886,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -910,6 +912,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -1406,6 +1409,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -1416,6 +1420,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -1441,6 +1446,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -1845,6 +1851,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -1855,6 +1862,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -1880,6 +1888,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -2282,6 +2291,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -2292,6 +2302,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -2317,6 +2328,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -2707,6 +2719,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -2717,6 +2730,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -2742,6 +2756,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -4536,6 +4551,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -4546,6 +4562,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -4571,6 +4588,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -4717,6 +4735,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -4727,6 +4746,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -4752,6 +4772,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -4964,6 +4985,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -4974,6 +4996,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -4999,6 +5022,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -5691,6 +5715,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -5701,6 +5726,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider__in] @@ -6378,6 +6404,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -6388,6 +6415,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -6413,6 +6441,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -6747,6 +6776,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -6757,6 +6787,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -6782,6 +6813,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -7017,6 +7049,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -7027,6 +7060,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -7052,6 +7086,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -7293,6 +7328,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -7303,6 +7339,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -7328,6 +7365,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -8132,6 +8170,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 - oci @@ -8142,6 +8181,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure - in: query name: filter[provider_type__in] @@ -8167,6 +8207,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC * `oci` - Oracle Cloud Infrastructure explode: false style: form @@ -14201,6 +14242,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -16127,6 +16179,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -16427,6 +16490,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -16745,6 +16819,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 97e6002e00..40835efee7 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1143,6 +1143,16 @@ def test_providers_invalid_retrieve(self, authenticated_client): "uid": "a12345678901234567890123456789012345678", "alias": "Long Username", }, + { + "provider": "iac", + "uid": "https://github.com/user/repo.git", + "alias": "Git Repo", + }, + { + "provider": "iac", + "uid": "https://gitlab.com/user/project", + "alias": "GitLab Repo", + }, ] ), ) @@ -1292,6 +1302,33 @@ def test_providers_create_valid(self, authenticated_client, provider_json_payloa "github-uid", "uid", ), + ( + { + "provider": "iac", + "uid": "not-a-url", + "alias": "test", + }, + "iac-uid", + "uid", + ), + ( + { + "provider": "iac", + "uid": "ftp://invalid-protocol.com/repo", + "alias": "test", + }, + "iac-uid", + "uid", + ), + ( + { + "provider": "iac", + "uid": "http://", + "alias": "test", + }, + "iac-uid", + "uid", + ), ] ), ) diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 0675c64d74..024c7581d7 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -18,6 +18,7 @@ from prowler.providers.common.models import Connection from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.github.github_provider import GithubProvider +from prowler.providers.iac.iac_provider import IacProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.oraclecloud.oci_provider import OciProvider @@ -66,6 +67,7 @@ def return_prowler_provider( | AzureProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider | OciProvider @@ -76,7 +78,7 @@ def return_prowler_provider( provider (Provider): The provider object containing the provider type and associated secrets. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: The corresponding provider class. + AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | OciProvider: The corresponding provider class. Raises: ValueError: If the provider type specified in `provider.provider` is not supported. @@ -94,6 +96,8 @@ def return_prowler_provider( prowler_provider = M365Provider case Provider.ProviderChoices.GITHUB.value: prowler_provider = GithubProvider + case Provider.ProviderChoices.IAC.value: + prowler_provider = IacProvider case Provider.ProviderChoices.OCI.value: prowler_provider = OciProvider case _: @@ -132,6 +136,16 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "organizations": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.IAC.value: + # For IaC provider, uid contains the repository URL + # Extract the access token if present in the secret + prowler_provider_kwargs = { + "scan_repository_url": provider.uid, + } + if "access_token" in provider.secret.secret: + prowler_provider_kwargs["oauth_app_token"] = provider.secret.secret[ + "access_token" + ] if mutelist_processor: mutelist_content = mutelist_processor.configuration.get("Mutelist", {}) @@ -149,6 +163,7 @@ def initialize_prowler_provider( | AzureProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider | OciProvider @@ -160,8 +175,8 @@ def initialize_prowler_provider( mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: An instance of the corresponding provider class - (`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `KubernetesProvider`, `M365Provider` or `OciProvider`) initialized with the + AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | OciProvider: An instance of the corresponding provider class + (`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `IacProvider`, `KubernetesProvider`, `M365Provider` or `OciProvider`) initialized with the provider's secrets. """ prowler_provider = return_prowler_provider(provider) @@ -185,9 +200,23 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: except Provider.secret.RelatedObjectDoesNotExist as secret_error: return Connection(is_connected=False, error=secret_error) - return prowler_provider.test_connection( - **prowler_provider_kwargs, provider_id=provider.uid, raise_on_exception=False - ) + # For IaC provider, construct the kwargs properly for test_connection + if provider.provider == Provider.ProviderChoices.IAC.value: + # Don't pass repository_url from secret, use scan_repository_url with the UID + iac_test_kwargs = { + "scan_repository_url": provider.uid, + "raise_on_exception": False, + } + # Add access_token if present in the secret + if "access_token" in prowler_provider_kwargs: + iac_test_kwargs["access_token"] = prowler_provider_kwargs["access_token"] + return prowler_provider.test_connection(**iac_test_kwargs) + else: + return prowler_provider.test_connection( + **prowler_provider_kwargs, + provider_id=provider.uid, + raise_on_exception=False, + ) def prowler_integration_connection_test(integration: Integration) -> Connection: diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 8a47865fbd..1965a16cb5 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -239,6 +239,21 @@ }, "required": ["github_app_id", "github_app_key"], }, + { + "type": "object", + "title": "IaC Repository Credentials", + "properties": { + "repository_url": { + "type": "string", + "description": "Repository URL to scan for IaC files.", + }, + "access_token": { + "type": "string", + "description": "Optional access token for private repositories.", + }, + }, + "required": ["repository_url"], + }, { "type": "object", "title": "Oracle Cloud Infrastructure (OCI) API Key Credentials", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 1af474e6de..0dd0fb53b5 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1362,6 +1362,8 @@ def validate_secret_based_on_provider( serializer = GCPProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.IAC.value: + serializer = IacProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.KUBERNETES.value: serializer = KubernetesProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.M365.value: @@ -1481,6 +1483,14 @@ class Meta: resource_name = "provider-secrets" +class IacProviderSecret(serializers.Serializer): + repository_url = serializers.CharField() + access_token = serializers.CharField(required=False) + + class Meta: + resource_name = "provider-secrets" + + class OracleCloudProviderSecret(serializers.Serializer): user = serializers.CharField() fingerprint = serializers.CharField() diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index b207285676..66319ec7eb 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -113,6 +113,10 @@ "github": [ (lambda name: name.startswith("cis_"), GithubCIS), ], + "iac": [ + # IaC provider doesn't have specific compliance frameworks yet + # Trivy handles its own compliance checks + ], "oci": [ (lambda name: name.startswith("cis_"), OCICIS), ], diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 7c9bb623d0..aa56177351 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,7 +32,7 @@ The supported providers right now are: | [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | UI, API, CLI | | [Github](/user-guide/providers/github/getting-started-github) | Official | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | UI, API, CLI | -| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | CLI | +| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | CLI | | [LLM](/user-guide/providers/llm/getting-started-llm) | Official | CLI | | **NHN** | Unofficial | CLI | diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index de74f598a6..a7c31bdc1f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -47,6 +47,11 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +### Changed +- Adapt IaC provider to be used in the Prowler App [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) + +--- + ## [v5.13.0] (Prowler v5.13.0) ### Added diff --git a/prowler/__main__.py b/prowler/__main__.py index 10a4ad7e21..7d578a7c6a 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -359,6 +359,12 @@ def streaming_callback(findings_batch): else: # Original behavior for IAC or non-verbose LLM findings = global_provider.run() + # Note: IaC doesn't support granular progress tracking since Trivy runs as a black box + # and returns all findings at once. Progress tracking would just be 0% → 100%. + + # Filter findings by status if specified + if hasattr(args, "status") and args.status: + findings = [f for f in findings if f.status in args.status] # Report findings for verbose output report(findings, global_provider, output_options) elif len(checks_to_execute): diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 6d3807a46e..1a558fd11d 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -309,10 +309,17 @@ def generate_output( output_data["auth_method"] = provider.auth_method output_data["account_uid"] = "iac" output_data["account_name"] = "iac" - output_data["resource_name"] = check_output.resource_name - output_data["resource_uid"] = check_output.resource_name - output_data["region"] = check_output.resource_line_range - output_data["resource_line_range"] = check_output.resource_line_range + output_data["resource_name"] = getattr( + check_output, "resource_name", "" + ) + output_data["resource_uid"] = getattr(check_output, "resource_name", "") + # For IaC, resource_line_range only exists on CheckReportIAC, not on Finding objects + output_data["region"] = getattr( + check_output, "resource_line_range", "file" + ) + output_data["resource_line_range"] = getattr( + check_output, "resource_line_range", "" + ) output_data["framework"] = check_output.check_metadata.ServiceName elif provider.type == "llm": @@ -407,6 +414,10 @@ def transform_api_finding(cls, finding, provider) -> "Finding": finding.subscription = list(provider.identity.subscriptions.keys())[0] elif provider.type == "gcp": finding.project_id = list(provider.projects.keys())[0] + elif provider.type == "iac": + # For IaC, we don't have resource_line_range in the Finding model + # It would need to be extracted from the resource metadata if needed + finding.resource_line_range = "" # Set empty for compatibility elif provider.type == "oci": finding.compartment_id = getattr(finding, "compartment_id", "") diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 8afe993f10..062ea80738 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -1,4 +1,5 @@ import datetime +from datetime import timezone from types import SimpleNamespace from typing import Generator @@ -25,6 +26,7 @@ ) from prowler.providers.common.models import Audit_Metadata, ProviderOutputOptions from prowler.providers.common.provider import Provider +from prowler.providers.iac.iac_provider import IacProvider class Scan: @@ -90,15 +92,25 @@ def __init__( except ValueError: raise ScanInvalidStatusError(f"Invalid status provided: {s}.") - # Load bulk compliance frameworks - self._bulk_compliance_frameworks = Compliance.get_bulk(provider.type) - - # Get bulk checks metadata for the provider - self._bulk_checks_metadata = CheckMetadata.get_bulk(provider.type) - # Complete checks metadata with the compliance framework specification - self._bulk_checks_metadata = update_checks_metadata_with_compliance( - self._bulk_compliance_frameworks, self._bulk_checks_metadata - ) + # Special setup for IaC provider - override inputs to work with traditional flow + if provider.type == "iac": + # IaC doesn't use traditional Prowler checks, so clear all input parameters + # to avoid validation errors and let it flow through the normal logic + checks = None + services = None + excluded_checks = None + excluded_services = None + self._bulk_checks_metadata = {} + self._bulk_compliance_frameworks = {} + else: + # Load bulk compliance frameworks + self._bulk_compliance_frameworks = Compliance.get_bulk(provider.type) + # Get bulk checks metadata for the provider + self._bulk_checks_metadata = CheckMetadata.get_bulk(provider.type) + # Complete checks metadata with the compliance framework specification + self._bulk_checks_metadata = update_checks_metadata_with_compliance( + self._bulk_compliance_frameworks, self._bulk_checks_metadata + ) # Create a list of valid categories valid_categories = set() @@ -148,19 +160,22 @@ def __init__( ) # Load checks to execute - self._checks_to_execute = sorted( - load_checks_to_execute( - bulk_checks_metadata=self._bulk_checks_metadata, - bulk_compliance_frameworks=self._bulk_compliance_frameworks, - check_list=checks, - service_list=services, - compliance_frameworks=compliances, - categories=categories, - severities=severities, - provider=provider.type, - checks_file=None, + if provider.type == "iac": + self._checks_to_execute = ["iac_scan"] # Dummy check name for IaC + else: + self._checks_to_execute = sorted( + load_checks_to_execute( + bulk_checks_metadata=self._bulk_checks_metadata, + bulk_compliance_frameworks=self._bulk_compliance_frameworks, + check_list=checks, + service_list=services, + compliance_frameworks=compliances, + categories=categories, + severities=severities, + provider=provider.type, + checks_file=None, + ) ) - ) # Exclude checks if excluded_checks: @@ -184,9 +199,13 @@ def __init__( self._number_of_checks_to_execute = len(self._checks_to_execute) - service_checks_to_execute = get_service_checks_to_execute( - self._checks_to_execute - ) + # Set up service-based checks tracking + if provider.type == "iac": + service_checks_to_execute = {"iac": set(["iac_scan"])} + else: + service_checks_to_execute = get_service_checks_to_execute( + self._checks_to_execute + ) service_checks_completed = dict() self._service_checks_to_execute = service_checks_to_execute @@ -245,6 +264,9 @@ def scan( Exception: If any other error occurs during the execution of a check. """ try: + # Initialize check_name for error handling + check_name = None + # Using SimpleNamespace to create a mocked object arguments = SimpleNamespace() @@ -266,6 +288,64 @@ def scan( start_time = datetime.datetime.now() + # Special handling for IaC provider + if self._provider.type == "iac": + # IaC provider doesn't use regular checks, it runs Trivy directly + if isinstance(self._provider, IacProvider): + logger.info("Running IaC scan with Trivy...") + # Run the IaC scan + iac_reports = self._provider.run() + + # Convert IaC reports to Finding objects + findings = [] + + for report in iac_reports: + # Generate unique UID for the finding + finding_uid = f"{report.check_metadata.CheckID}-{report.resource_name}-{report.resource_line_range}" + + # Convert status string to Status enum + status_enum = ( + Status.FAIL if report.status == "FAIL" else Status.PASS + ) + if report.muted: + status_enum = Status.MUTED + + finding = Finding( + auth_method="Repository", # IaC uses repository as auth method + timestamp=datetime.datetime.now(timezone.utc), + account_uid=self._provider.scan_repository_url or "local", + account_name="IaC Repository", + metadata=report.check_metadata, # Pass the CheckMetadata object directly + uid=finding_uid, + status=status_enum, + status_extended=report.status_extended, + muted=report.muted, + resource_uid=report.resource_name, # For IaC, the file path is the UID + resource_metadata=report.resource, # The raw finding dict + resource_name=report.resource_name, + resource_details=report.resource_details, + resource_tags={}, # IaC doesn't have resource tags + region="global", # IaC doesn't have regions + compliance={}, # IaC doesn't have compliance mappings yet + raw=report.resource, # The raw finding dict + ) + findings.append(finding) + + # Filter the findings by the status + if self._status: + findings = [f for f in findings if f.status in self._status] + + # Update progress and yield findings + self._number_of_checks_completed = 1 + self._number_of_checks_to_execute = 1 + + yield (100.0, findings) + + # Calculate duration + end_time = datetime.datetime.now() + self._duration = int((end_time - start_time).total_seconds()) + return + for check_name in checks_to_execute: try: # Recover service from check name @@ -349,6 +429,7 @@ def scan( # Update the scan duration when all checks are completed self._duration = int((datetime.datetime.now() - start_time).total_seconds()) except Exception as error: + check_name = check_name or "Scan error" logger.error( f"{check_name} - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index 4e658c7849..15e9cda176 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -1,10 +1,11 @@ import json +import re import shutil import subprocess import sys import tempfile from os import environ -from typing import List +from typing import Generator, List from alive_progress import alive_bar from colorama import Fore, Style @@ -17,7 +18,7 @@ from prowler.lib.check.models import CheckReportIAC from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes -from prowler.providers.common.models import Audit_Metadata +from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider @@ -173,7 +174,7 @@ def _process_finding( "Severity": finding["Severity"], "ResourceType": "iac", "Description": finding_description, - "Risk": "", + "Risk": "This provider has not defined a risk for this check.", "RelatedUrl": finding.get("PrimaryURL", ""), "Remediation": { "Code": { @@ -242,20 +243,39 @@ def _clone_repository( logger.info( f"Cloning repository {original_url} into {temporary_directory}..." ) - with alive_bar( - ctrl_c=False, - bar="blocks", - spinner="classic", - stats=False, - enrich_print=False, - ) as bar: - try: - bar.title = f"-> Cloning {original_url}..." + + # Check if we're in an environment with a TTY + # Celery workers and other non-interactive environments don't have TTY + # and cannot use the alive_bar + try: + if sys.stdout.isatty(): + with alive_bar( + ctrl_c=False, + bar="blocks", + spinner="classic", + stats=False, + enrich_print=False, + ) as bar: + try: + bar.title = f"-> Cloning {original_url}..." + porcelain.clone( + repository_url, temporary_directory, depth=1 + ) + bar.title = "-> Repository cloned successfully!" + except Exception as clone_error: + bar.title = "-> Cloning failed!" + raise clone_error + else: + # No TTY, just clone without progress bar + logger.info(f"Cloning {original_url}...") porcelain.clone(repository_url, temporary_directory, depth=1) - bar.title = "-> Repository cloned successfully!" - except Exception as clone_error: - bar.title = "-> Cloning failed!" - raise clone_error + logger.info("Repository cloned successfully!") + except (AttributeError, OSError): + # Fallback if isatty() check fails + logger.info(f"Cloning {original_url}...") + porcelain.clone(repository_url, temporary_directory, depth=1) + logger.info("Repository cloned successfully!") + return temporary_directory except Exception as error: logger.critical( @@ -275,7 +295,10 @@ def run(self) -> List[CheckReportIAC]: scan_dir = self.scan_path try: - reports = self.run_scan(scan_dir, self.scanners, self.exclude_path) + # Collect all batches from the generator + reports = [] + for batch in self.run_scan(scan_dir, self.scanners, self.exclude_path): + reports.extend(batch) finally: if temp_dir: logger.info(f"Removing temporary directory {temp_dir}...") @@ -285,7 +308,7 @@ def run(self) -> List[CheckReportIAC]: def run_scan( self, directory: str, scanners: list[str], exclude_path: list[str] - ) -> List[CheckReportIAC]: + ) -> Generator[List[CheckReportIAC], None, None]: try: logger.info(f"Running IaC scan on {directory} ...") trivy_command = [ @@ -302,25 +325,47 @@ def run_scan( ] if exclude_path: trivy_command.extend(["--skip-dirs", ",".join(exclude_path)]) - with alive_bar( - ctrl_c=False, - bar="blocks", - spinner="classic", - stats=False, - enrich_print=False, - ) as bar: - try: - bar.title = f"-> Running IaC scan on {directory} ..." - # Run Trivy with JSON output + + # Check if we're in an environment with a TTY + try: + if sys.stdout.isatty(): + with alive_bar( + ctrl_c=False, + bar="blocks", + spinner="classic", + stats=False, + enrich_print=False, + ) as bar: + try: + bar.title = f"-> Running IaC scan on {directory} ..." + # Run Trivy with JSON output + process = subprocess.run( + trivy_command, + capture_output=True, + text=True, + ) + bar.title = "-> Scan completed!" + except Exception as error: + bar.title = "-> Scan failed!" + raise error + else: + # No TTY, just run without progress bar + logger.info(f"Running Trivy scan on {directory}...") process = subprocess.run( trivy_command, capture_output=True, text=True, ) - bar.title = "-> Scan completed!" - except Exception as error: - bar.title = "-> Scan failed!" - raise error + logger.info("Trivy scan completed!") + except (AttributeError, OSError): + # Fallback if isatty() check fails + logger.info(f"Running Trivy scan on {directory}...") + process = subprocess.run( + trivy_command, + capture_output=True, + text=True, + ) + logger.info("Trivy scan completed!") # Log Trivy's stderr output with preserved log levels if process.stderr: for line in process.stderr.strip().split("\n"): @@ -354,14 +399,15 @@ def run_scan( if not output: logger.warning("No findings returned from Trivy scan") - return [] + return except Exception as error: logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) sys.exit(1) - reports = [] + batch = [] + batch_size = 100 # Process all trivy findings for finding in output: @@ -371,27 +417,44 @@ def run_scan( report = self._process_finding( misconfiguration, finding["Target"], finding["Type"] ) - reports.append(report) + batch.append(report) + if len(batch) >= batch_size: + yield batch + batch = [] + # Process Vulnerabilities for vulnerability in finding.get("Vulnerabilities", []): report = self._process_finding( vulnerability, finding["Target"], finding["Type"] ) - reports.append(report) + batch.append(report) + if len(batch) >= batch_size: + yield batch + batch = [] + # Process Secrets for secret in finding.get("Secrets", []): report = self._process_finding( secret, finding["Target"], finding["Class"] ) - reports.append(report) + batch.append(report) + if len(batch) >= batch_size: + yield batch + batch = [] + # Process Licenses for license in finding.get("Licenses", []): report = self._process_finding( license, finding["Target"], finding["Type"] ) - reports.append(report) + batch.append(report) + if len(batch) >= batch_size: + yield batch + batch = [] - return reports + # Yield any remaining findings in the last batch + if batch: + yield batch except Exception as error: if "No such file or directory: 'trivy'" in str(error): @@ -434,3 +497,95 @@ def print_credentials(self): ) print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + scan_repository_url: str = None, + oauth_app_token: str = None, + access_token: str = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> "Connection": + """Test connection to IaC repository. + + Test the connection to the IaC repository using the provided credentials. + + Args: + scan_repository_url (str): Repository URL to scan. + oauth_app_token (str): OAuth App token for authentication. + access_token (str): Access token for authentication (alias for oauth_app_token). + raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails. + provider_id (str): The provider ID, in this case it's the repository URL. + + Returns: + Connection: Connection object with success status or error information. + + Raises: + Exception: If failed to test the connection to the repository. + + Examples: + >>> IacProvider.test_connection(scan_repository_url="https://github.com/user/repo") + Connection(is_connected=True) + """ + try: + # If provider_id is provided and scan_repository_url is not, use provider_id as the repository URL + if provider_id and not scan_repository_url: + scan_repository_url = provider_id + + # Handle both oauth_app_token and access_token parameters + if access_token and not oauth_app_token: + oauth_app_token = access_token + + if not scan_repository_url: + return Connection( + is_connected=False, error="Repository URL is required" + ) + + # Try to clone the repository to test the connection + with tempfile.TemporaryDirectory(): + try: + if oauth_app_token: + # If token is provided, use it for authentication + # Extract the domain and path from the URL + url_pattern = r"(https?://)([^/]+)/(.+)" + match = re.match(url_pattern, scan_repository_url) + if match: + protocol, domain, path = match.groups() + # Construct URL with token + auth_url = f"{protocol}x-access-token:{oauth_app_token}@{domain}/{path}" + else: + auth_url = scan_repository_url + else: + # Public repository + auth_url = scan_repository_url + + # Use dulwich to test the connection + porcelain.ls_remote(auth_url) + + return Connection(is_connected=True) + + except Exception as e: + error_msg = str(e) + if "authentication" in error_msg.lower() or "401" in error_msg: + return Connection( + is_connected=False, + error="Authentication failed. Please check your access token.", + ) + elif "404" in error_msg or "not found" in error_msg.lower(): + return Connection( + is_connected=False, + error="Repository not found or not accessible.", + ) + else: + return Connection( + is_connected=False, + error=f"Failed to connect to repository: {error_msg}", + ) + + except Exception as error: + if raise_on_exception: + raise + return Connection( + is_connected=False, + error=f"Unexpected error testing connection: {str(error)}", + ) diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index 1699902e12..e6ff948ed7 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -260,16 +260,24 @@ def test_init_with_no_checks( assert scan.get_completed_services() == set() assert scan.get_completed_checks() == set() - @patch("importlib.import_module") + @patch("prowler.lib.scan.scan.load_checks_to_execute") + @patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance") + @patch("prowler.lib.scan.scan.Compliance.get_bulk") + @patch("prowler.lib.scan.scan.CheckMetadata.get_bulk") + @patch("prowler.lib.scan.scan.import_check") def test_scan( - mock_import_module, + self, + mock_import_check, + mock_get_bulk, + mock_compliance_get_bulk, + mock_update_checks_metadata, + mock_load_checks, mock_global_provider, mock_execute, mock_logger, - mock_generate_output, - mock_recover_checks_from_provider, - mock_load_check_metadata, ): + from prowler.lib.check.models import Severity + mock_check_class = MagicMock() mock_check_instance = mock_check_class.return_value mock_check_instance.Provider = "aws" @@ -277,23 +285,39 @@ def test_scan( mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled" mock_check_instance.Categories = [] - mock_import_module.return_value = MagicMock( + mock_import_check.return_value = MagicMock( accessanalyzer_enabled=mock_check_class ) checks_to_execute = {"accessanalyzer_enabled"} custom_checks_metadata = {} - mock_global_provider.type = "aws" + + # Mock CheckMetadata + mock_metadata = MagicMock() + mock_metadata.CheckID = "accessanalyzer_enabled" + mock_metadata.ResourceType = "AWS::IAM::AccessAnalyzer" + mock_metadata.Categories = [] + mock_metadata.CheckAliases = [] + mock_metadata.Severity = Severity.medium + mock_metadata.Compliance = [] + + bulk_checks_metadata = {"accessanalyzer_enabled": mock_metadata} + mock_get_bulk.return_value = bulk_checks_metadata + + # Mock update_checks_metadata_with_compliance to return the same metadata + mock_update_checks_metadata.return_value = bulk_checks_metadata + + # Mock Compliance frameworks + mock_compliance_get_bulk.return_value = {} + + # Mock load_checks_to_execute to return the checks + mock_load_checks.return_value = ["accessanalyzer_enabled"] scan = Scan(mock_global_provider, checks=checks_to_execute) - mock_load_check_metadata.assert_called_once() - mock_recover_checks_from_provider.assert_called_once_with("aws") results = list(scan.scan(custom_checks_metadata)) - assert mock_generate_output.call_count == 1 * len(mock_execute.side_effect()) assert mock_execute.call_count == 1 assert len(results) == 1 - assert results[0][1] == mock_execute.side_effect() assert results[0][0] == 100.0 assert scan.progress == 100.0 # Since the scan is mocked, the duration will always be 0 for now diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index 7f89ff7a49..8fb6fc48f9 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -86,9 +86,12 @@ def test_iac_provider_run_scan_success(self, mock_subprocess): stdout=get_sample_trivy_json_output(), stderr="" ) - reports = provider.run_scan( + # Collect all batches from the generator + reports = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + reports.extend(batch) # Should have 3 misconfigurations from the sample output assert len(reports) == 3 @@ -124,9 +127,11 @@ def test_iac_provider_run_scan_empty_output(self, mock_subprocess): stdout=get_empty_trivy_output(), stderr="" ) - reports = provider.run_scan( + reports = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + reports.extend(batch) assert len(reports) == 0 def test_provider_run_local_scan(self): @@ -200,7 +205,9 @@ def test_iac_provider_process_check_medium_severity(self, mock_subprocess): ) with pytest.raises(SystemExit) as excinfo: - provider.run_scan("/test/directory", ["all"], []) + # Consume the generator + for _ in provider.run_scan("/test/directory", ["all"], []): + pass assert excinfo.value.code == 1 @@ -212,7 +219,11 @@ def test_iac_provider_run_scan_null_output(self, mock_subprocess): mock_subprocess.return_value = MagicMock(stdout="null", stderr="") with pytest.raises(SystemExit) as exc_info: - provider.run_scan("/test/directory", ["vuln", "misconfig", "secret"], []) + # Consume the generator + for _ in provider.run_scan( + "/test/directory", ["vuln", "misconfig", "secret"], [] + ): + pass assert exc_info.value.code == 1 def test_iac_provider_process_finding_dockerfile(self): @@ -264,9 +275,11 @@ def test_run_scan_success_with_failed_and_passed_checks(self, mock_subprocess): stdout=json.dumps(sample_output), stderr="" ) - result = provider.run_scan( + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert len(result) == 2 @@ -299,9 +312,11 @@ def test_run_scan_with_skipped_checks(self, mock_subprocess): stdout=json.dumps(sample_output), stderr="" ) - result = provider.run_scan( + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], ["exclude/path"] - ) + ): + result.extend(batch) # Verify results assert len(result) == 1 @@ -318,9 +333,11 @@ def test_run_scan_empty_results(self, mock_subprocess): stdout=json.dumps({"Results": []}), stderr="" ) - result = provider.run_scan( + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert len(result) == 0 @@ -356,9 +373,11 @@ def test_run_scan_multiple_reports(self, mock_subprocess): stdout=json.dumps(sample_output), stderr="" ) - result = provider.run_scan( + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert len(result) == 2 @@ -377,7 +396,11 @@ def test_run_scan_exception_handling(self, mock_subprocess): mock_subprocess.side_effect = Exception("Test exception") with pytest.raises(SystemExit) as exc_info: - provider.run_scan("/test/directory", ["vuln", "misconfig", "secret"], []) + # Consume the generator + for _ in provider.run_scan( + "/test/directory", ["vuln", "misconfig", "secret"], [] + ): + pass assert exc_info.value.code == 1 @@ -405,7 +428,9 @@ def test_run_scan_with_different_frameworks(self, mock_subprocess): # Test with specific scanners scanners = ["vuln", "misconfig", "secret"] - result = provider.run_scan("/test/directory", scanners, []) + result = [] + for batch in provider.run_scan("/test/directory", scanners, []): + result.extend(batch) # Verify subprocess was called with correct scanners mock_subprocess.assert_called_once_with( @@ -453,9 +478,11 @@ def test_run_scan_with_exclude_paths(self, mock_subprocess): # Test with exclude paths exclude_paths = ["node_modules", ".git", "vendor"] - result = provider.run_scan( + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], exclude_paths - ) + ): + result.extend(batch) # Verify subprocess was called with correct exclude paths expected_command = [ @@ -510,9 +537,12 @@ def test_run_scan_all_check_types(self, mock_subprocess): stdout=json.dumps(sample_output), stderr="" ) - result = provider.run_scan( + # Consume the generator to get all batches + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert len(result) == 5 # 5 misconfigurations @@ -536,9 +566,12 @@ def test_run_scan_no_reports_returned(self, mock_subprocess): stdout=json.dumps({"Results": []}), stderr="" ) - result = provider.run_scan( + # Consume the generator to get all batches + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert len(result) == 0 @@ -592,9 +625,12 @@ def test_run_scan_multiple_frameworks_with_different_checks(self, mock_subproces stdout=json.dumps(sample_output), stderr="" ) - result = provider.run_scan( + # Consume the generator to get all batches + result = [] + for batch in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] - ) + ): + result.extend(batch) # Verify results assert ( @@ -615,7 +651,8 @@ def test_run_method_calls_run_scan(self): ) with patch.object(provider, "run_scan") as mock_run_scan: - mock_run_scan.return_value = [] + # Mock should return a generator (empty in this case) + mock_run_scan.return_value = iter([]) provider.run() mock_run_scan.assert_called_once_with( diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9ff46abb81..e81eef512f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file. - RSS feeds support [(#9109)](https://github.com/prowler-cloud/prowler/pull/9109) - Customer Support menu item [(#9143)](https://github.com/prowler-cloud/prowler/pull/9143) +- IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) ### 🔄 Changed @@ -20,7 +21,6 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Upgrade React to version 19.2.0 [(#9039)](https://github.com/prowler-cloud/prowler/pull/9039) -- Add support for Oracle Cloud Infrastructure (OCI) provider [(#8984)](https://github.com/prowler-cloud/prowler/pull/8984) --- @@ -87,6 +87,8 @@ All notable changes to the **Prowler UI** are documented in this file. - Field-level email validation message [(#8698)](https://github.com/prowler-cloud/prowler/pull/8698) - POST method on auth form [(#8699)](https://github.com/prowler-cloud/prowler/pull/8699) +--- + ## [1.12.0] (Prowler v5.12.0) ### 🚀 Added diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index dce72229bd..08e708b19f 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -139,6 +139,24 @@ export const addCredentialsProvider = async (formData: FormData) => { ) as string | undefined; try { + // For IaC provider, fetch the provider data to get the repository URL from uid + if (providerType === "iac") { + const providerUrl = new URL(`${apiBaseUrl}/providers/${providerId}`); + const providerResponse = await fetch(providerUrl.toString(), { + headers: await getAuthHeaders({ contentType: false }), + }); + + if (providerResponse.ok) { + const providerData = await providerResponse.json(); + const providerUid = providerData?.data?.attributes?.uid; + + // Add the repository URL to formData using the provider's uid + if (providerUid) { + formData.append(ProviderCredentialFields.REPOSITORY_URL, providerUid); + } + } + } + const { secretType, secret } = buildSecretConfig( formData, providerType, diff --git a/ui/app/(prowler)/new-overview/components/accounts-selector.tsx b/ui/app/(prowler)/new-overview/components/accounts-selector.tsx index e68edf56ea..2d20ace068 100644 --- a/ui/app/(prowler)/new-overview/components/accounts-selector.tsx +++ b/ui/app/(prowler)/new-overview/components/accounts-selector.tsx @@ -8,6 +8,7 @@ import { AzureProviderBadge, GCPProviderBadge, GitHubProviderBadge, + IacProviderBadge, KS8ProviderBadge, M365ProviderBadge, OracleCloudProviderBadge, @@ -28,6 +29,7 @@ const PROVIDER_ICON: Record = { kubernetes: , m365: , github: , + iac: , oci: , }; diff --git a/ui/app/(prowler)/new-overview/components/provider-type-selector.tsx b/ui/app/(prowler)/new-overview/components/provider-type-selector.tsx index aa1f36dace..8329a13282 100644 --- a/ui/app/(prowler)/new-overview/components/provider-type-selector.tsx +++ b/ui/app/(prowler)/new-overview/components/provider-type-selector.tsx @@ -42,6 +42,11 @@ const GitHubProviderBadge = lazy(() => default: m.GitHubProviderBadge, })), ); +const IacProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.IacProviderBadge, + })), +); const OracleCloudProviderBadge = lazy(() => import("@/components/icons/providers-badge").then((m) => ({ default: m.OracleCloudProviderBadge, @@ -82,6 +87,10 @@ const PROVIDER_DATA: Record< label: "GitHub", icon: GitHubProviderBadge, }, + iac: { + label: "Infrastructure as Code", + icon: IacProviderBadge, + }, oci: { 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 6a2c5cb078..1ca770157c 100644 --- a/ui/components/filters/custom-provider-inputs.tsx +++ b/ui/components/filters/custom-provider-inputs.tsx @@ -5,6 +5,7 @@ import { AzureProviderBadge, GCPProviderBadge, GitHubProviderBadge, + IacProviderBadge, KS8ProviderBadge, M365ProviderBadge, OracleCloudProviderBadge, @@ -64,6 +65,15 @@ export const CustomProviderInputGitHub = () => { ); }; +export const CustomProviderInputIac = () => { + return ( +
+ +

Infrastructure as Code

+
+ ); +}; + export const CustomProviderInputOracleCloud = () => { return (
diff --git a/ui/components/filters/custom-select-provider.tsx b/ui/components/filters/custom-select-provider.tsx index c72830a2a8..e2958110ab 100644 --- a/ui/components/filters/custom-select-provider.tsx +++ b/ui/components/filters/custom-select-provider.tsx @@ -11,6 +11,7 @@ import { CustomProviderInputAzure, CustomProviderInputGCP, CustomProviderInputGitHub, + CustomProviderInputIac, CustomProviderInputKubernetes, CustomProviderInputM365, CustomProviderInputOracleCloud, @@ -44,6 +45,10 @@ const providerDisplayData: Record< label: "GitHub", component: , }, + iac: { + label: "Infrastructure as Code", + component: , + }, oci: { label: "Oracle Cloud Infrastructure", component: , diff --git a/ui/components/icons/providers-badge/iac-provider-badge.tsx b/ui/components/icons/providers-badge/iac-provider-badge.tsx new file mode 100644 index 0000000000..9a701606ed --- /dev/null +++ b/ui/components/icons/providers-badge/iac-provider-badge.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const IacProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts index b143c27d6c..924aa1b6b8 100644 --- a/ui/components/icons/providers-badge/index.ts +++ b/ui/components/icons/providers-badge/index.ts @@ -2,6 +2,7 @@ export * from "./aws-provider-badge"; export * from "./azure-provider-badge"; export * from "./gcp-provider-badge"; export * from "./github-provider-badge"; +export * from "./iac-provider-badge"; export * from "./ks8-provider-badge"; export * from "./m365-provider-badge"; export * from "./oraclecloud-provider-badge"; diff --git a/ui/components/overview/provider-overview/provider-overview.tsx b/ui/components/overview/provider-overview/provider-overview.tsx index fdbd4a81cc..03dbeb744c 100644 --- a/ui/components/overview/provider-overview/provider-overview.tsx +++ b/ui/components/overview/provider-overview/provider-overview.tsx @@ -8,6 +8,7 @@ import { AzureProviderBadge, GCPProviderBadge, GitHubProviderBadge, + IacProviderBadge, KS8ProviderBadge, M365ProviderBadge, OracleCloudProviderBadge, @@ -38,6 +39,8 @@ export const ProvidersOverview = ({ return ; case "github": return ; + case "iac": + return ; case "oci": return ; default: @@ -52,6 +55,7 @@ export const ProvidersOverview = ({ gcp: "GCP", kubernetes: "Kubernetes", github: "GitHub", + iac: "IaC", oci: "OCI", }; diff --git a/ui/components/providers/enhanced-provider-selector.tsx b/ui/components/providers/enhanced-provider-selector.tsx index 66c07f9a34..cd1453c3bf 100644 --- a/ui/components/providers/enhanced-provider-selector.tsx +++ b/ui/components/providers/enhanced-provider-selector.tsx @@ -17,6 +17,7 @@ const providerTypeLabels: Record = { m365: "Microsoft 365", kubernetes: "Kubernetes", github: "GitHub", + iac: "Infrastructure as Code", oci: "Oracle Cloud Infrastructure", }; diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index 7ebfec2a91..c507823593 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -12,6 +12,7 @@ import { AzureProviderBadge, GCPProviderBadge, GitHubProviderBadge, + IacProviderBadge, KS8ProviderBadge, M365ProviderBadge, OracleCloudProviderBadge, @@ -79,6 +80,12 @@ export const RadioGroupProvider: React.FC = ({ GitHub
+ +
+ + Infrastructure as Code +
+
)} + {providerType === "iac" && ( + } + /> + )} {providerType === "oci" && ( } diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index f9bf6bafeb..5fbdb48692 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -51,6 +51,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => { label: "Username", placeholder: "e.g. your-github-username", }; + case "iac": + return { + label: "Repository URL", + placeholder: "e.g. https://github.com/user/repo", + }; case "oci": return { label: "Tenancy OCID", diff --git a/ui/components/providers/workflow/forms/via-credentials/iac-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/iac-credentials-form.tsx new file mode 100644 index 0000000000..e44ea30a1c --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/iac-credentials-form.tsx @@ -0,0 +1,33 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { IacCredentials } from "@/types"; + +export const IacCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Repository +
+
+ Provide an access token if the repository is private (optional). +
+
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/index.ts b/ui/components/providers/workflow/forms/via-credentials/index.ts index 982e5ca701..daf77c0614 100644 --- a/ui/components/providers/workflow/forms/via-credentials/index.ts +++ b/ui/components/providers/workflow/forms/via-credentials/index.ts @@ -1,3 +1,4 @@ export * from "./azure-credentials-form"; export * from "./github-credentials-form"; +export * from "./iac-credentials-form"; export * from "./k8s-credentials-form"; diff --git a/ui/components/ui/entities/get-provider-logo.tsx b/ui/components/ui/entities/get-provider-logo.tsx index 7d2f6e5940..590688729b 100644 --- a/ui/components/ui/entities/get-provider-logo.tsx +++ b/ui/components/ui/entities/get-provider-logo.tsx @@ -5,6 +5,7 @@ import { AzureProviderBadge, GCPProviderBadge, GitHubProviderBadge, + IacProviderBadge, KS8ProviderBadge, M365ProviderBadge, OracleCloudProviderBadge, @@ -25,6 +26,8 @@ export const getProviderLogo = (provider: ProviderType) => { return ; case "github": return ; + case "iac": + return ; case "oci": return ; default: @@ -46,6 +49,8 @@ export const getProviderName = (provider: ProviderType): string => { return "Microsoft 365"; case "github": return "GitHub"; + case "iac": + return "Infrastructure as Code"; case "oci": return "Oracle Cloud Infrastructure"; default: diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 6df7753856..d59d5dd729 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -32,6 +32,11 @@ export const getProviderHelpText = (provider: string) => { text: "Need help connecting your GitHub account?", link: "https://goto.prowler.com/provider-github", }; + case "iac": + return { + text: "Need help scanning your Infrastructure as Code repository?", + link: "https://goto.prowler.com/provider-iac", + }; case "oci": 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 059f1a91d9..5435296d7b 100644 --- a/ui/lib/provider-credentials/build-crendentials.ts +++ b/ui/lib/provider-credentials/build-crendentials.ts @@ -197,6 +197,20 @@ export const buildGitHubSecret = (formData: FormData) => { return {}; }; +export const buildIacSecret = (formData: FormData) => { + const secret = { + [ProviderCredentialFields.REPOSITORY_URL]: getFormValue( + formData, + ProviderCredentialFields.REPOSITORY_URL, + ), + [ProviderCredentialFields.ACCESS_TOKEN]: getFormValue( + formData, + ProviderCredentialFields.ACCESS_TOKEN, + ), + }; + return filterEmptyValues(secret); +}; + /** * Utility function to safely encode a string to base64 * Handles UTF-8 characters properly without using deprecated APIs @@ -286,6 +300,10 @@ export const buildSecretConfig = ( secretType: "static", secret: buildGitHubSecret(formData), }), + iac: () => ({ + secretType: "static", + secret: buildIacSecret(formData), + }), oci: () => ({ 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 c60ecf3298..43b2db6f93 100644 --- a/ui/lib/provider-credentials/provider-credential-fields.ts +++ b/ui/lib/provider-credentials/provider-credential-fields.ts @@ -45,6 +45,10 @@ export const ProviderCredentialFields = { GITHUB_APP_ID: "github_app_id", GITHUB_APP_KEY: "github_app_key_content", + // IaC fields + REPOSITORY_URL: "repository_url", + ACCESS_TOKEN: "access_token", + // OCI fields OCI_USER: "user", OCI_FINGERPRINT: "fingerprint", @@ -81,6 +85,8 @@ export const ErrorPointers = { OAUTH_APP_TOKEN: "/data/attributes/secret/oauth_app_token", GITHUB_APP_ID: "/data/attributes/secret/github_app_id", GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content", + REPOSITORY_URL: "/data/attributes/secret/repository_url", + ACCESS_TOKEN: "/data/attributes/secret/access_token", 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 21dfeccf79..83544aed40 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -248,6 +248,12 @@ export type KubernetesCredentials = { [ProviderCredentialFields.PROVIDER_ID]: string; }; +export type IacCredentials = { + [ProviderCredentialFields.REPOSITORY_URL]: string; + [ProviderCredentialFields.ACCESS_TOKEN]?: string; + [ProviderCredentialFields.PROVIDER_ID]: string; +}; + export type OCICredentials = { [ProviderCredentialFields.OCI_USER]: string; [ProviderCredentialFields.OCI_FINGERPRINT]: string; @@ -264,6 +270,7 @@ export type CredentialsFormSchema = | GCPDefaultCredentials | GCPServiceAccountKey | KubernetesCredentials + | IacCredentials | M365Credentials | OCICredentials; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index f4966fe36d..d5cca88218 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -110,6 +110,11 @@ export const addProviderFormSchema = z [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), }), + z.object({ + providerType: z.literal("iac"), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), + providerUid: z.string(), + }), z.object({ providerType: z.literal("oci"), [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), @@ -196,28 +201,37 @@ export const addCredentialsFormSchema = ( .string() .optional(), } - : providerType === "oci" + : providerType === "iac" ? { - [ProviderCredentialFields.OCI_USER]: z + [ProviderCredentialFields.REPOSITORY_URL]: z .string() - .min(1, "User OCID is required"), - [ProviderCredentialFields.OCI_FINGERPRINT]: z - .string() - .min(1, "Fingerprint is required"), - [ProviderCredentialFields.OCI_KEY_CONTENT]: z - .string() - .min(1, "Private Key Content is required"), - [ProviderCredentialFields.OCI_TENANCY]: z - .string() - .min(1, "Tenancy OCID is required"), - [ProviderCredentialFields.OCI_REGION]: z + .optional(), + [ProviderCredentialFields.ACCESS_TOKEN]: z .string() - .min(1, "Region is required"), - [ProviderCredentialFields.OCI_PASS_PHRASE]: z - .union([z.string(), z.literal("")]) .optional(), } - : {}), + : providerType === "oci" + ? { + [ProviderCredentialFields.OCI_USER]: z + .string() + .min(1, "User OCID is required"), + [ProviderCredentialFields.OCI_FINGERPRINT]: z + .string() + .min(1, "Fingerprint is required"), + [ProviderCredentialFields.OCI_KEY_CONTENT]: z + .string() + .min(1, "Private Key Content is required"), + [ProviderCredentialFields.OCI_TENANCY]: z + .string() + .min(1, "Tenancy OCID is required"), + [ProviderCredentialFields.OCI_REGION]: z + .string() + .min(1, "Region is required"), + [ProviderCredentialFields.OCI_PASS_PHRASE]: z + .union([z.string(), z.literal("")]) + .optional(), + } + : {}), }) .superRefine((data: Record, ctx) => { if (providerType === "m365") { diff --git a/ui/types/providers.ts b/ui/types/providers.ts index 658101c1ed..9b2de65eb2 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -5,6 +5,7 @@ export const PROVIDER_TYPES = [ "kubernetes", "m365", "github", + "iac", "oci", ] as const;