diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 28ace1fb1b..ee76b5e58a 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the **Prowler API** are documented in this file. +## [Unreleased] + +### Added +- IaC (Infrastructure as Code) provider support for remote repositories [(#TBD)](https://github.com/prowler-cloud/prowler/pull/TBD) + +--- + ## [1.13.0] (Prowler 5.12.0) ### Added diff --git a/api/Dockerfile b/api/Dockerfile index a3cfb21782..acb613a147 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libtool \ libxslt1-dev \ python3-dev \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* # Install PowerShell @@ -36,6 +37,25 @@ RUN ARCH=$(uname -m) && \ ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh && \ rm /tmp/powershell.tar.gz +# Install Trivy for IaC scanning +ARG TRIVY_VERSION=0.66.0 +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/0048_iac_provider.py b/api/src/backend/api/migrations/0048_iac_provider.py new file mode 100644 index 0000000000..b266405e47 --- /dev/null +++ b/api/src/backend/api/migrations/0048_iac_provider.py @@ -0,0 +1,34 @@ +# 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", "0047_remove_integration_unique_configuration_per_tenant"), + ] + + 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"), + ("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 9766afc8d1..d1e4fd53c8 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -215,6 +215,7 @@ class Provider(RowLevelSecurityProtectedModel): KUBERNETES = "kubernetes", _("Kubernetes") M365 = "m365", _("M365") GITHUB = "github", _("GitHub") + IAC = "iac", _("IaC") @staticmethod def validate_aws_uid(value): @@ -285,6 +286,19 @@ class Provider(RowLevelSecurityProtectedModel): 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", + ) + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 06944e5389..4505a7537c 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -601,6 +601,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -610,6 +611,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -633,6 +635,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -1128,6 +1131,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -1137,6 +1141,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -1160,6 +1165,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -1563,6 +1569,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -1572,6 +1579,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -1595,6 +1603,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -1996,6 +2005,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -2005,6 +2015,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -2028,6 +2039,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -2417,6 +2429,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -2426,6 +2439,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -2449,6 +2463,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -3347,6 +3362,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -3356,6 +3372,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -3379,6 +3396,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -3514,6 +3532,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -3523,6 +3542,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -3546,6 +3566,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -3716,6 +3737,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -3725,6 +3747,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -3748,6 +3771,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -4430,6 +4454,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -4439,6 +4464,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider__in] schema: @@ -5044,6 +5070,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -5053,6 +5080,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -5076,6 +5104,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -5409,6 +5438,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -5418,6 +5448,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -5441,6 +5472,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -5675,6 +5707,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -5684,6 +5717,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -5707,6 +5741,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -5947,6 +5982,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -5956,6 +5992,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -5979,6 +6016,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -6779,6 +6817,7 @@ paths: - azure - gcp - github + - iac - kubernetes - m365 description: |- @@ -6788,6 +6827,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC - in: query name: filter[provider_type__in] schema: @@ -6811,6 +6851,7 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + * `iac` - IaC explode: false style: form - in: query @@ -11767,6 +11808,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 writeOnly: true required: - secret @@ -13540,6 +13592,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 writeOnly: true required: - secret_type @@ -13788,6 +13851,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 writeOnly: true required: - secret_type @@ -14052,6 +14126,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 writeOnly: true required: - secret diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 1aaf6224a8..fe219b437f 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -991,6 +991,16 @@ class TestProviderViewSet: "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", + }, ] ), ) @@ -1140,6 +1150,33 @@ class TestProviderViewSet: "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 97325d9240..96eb3d30f2 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -18,6 +18,7 @@ from prowler.providers.azure.azure_provider import AzureProvider 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 @@ -65,6 +66,7 @@ def return_prowler_provider( | AzureProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider ]: @@ -74,7 +76,7 @@ def return_prowler_provider( provider (Provider): The provider object containing the provider type and associated secrets. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider: The corresponding provider class. + AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider: The corresponding provider class. Raises: ValueError: If the provider type specified in `provider.provider` is not supported. @@ -92,6 +94,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 _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -128,6 +132,17 @@ 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 + # TODO: review this + 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", {}) @@ -145,6 +160,7 @@ def initialize_prowler_provider( | AzureProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider ): @@ -155,8 +171,8 @@ def initialize_prowler_provider( mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider: An instance of the corresponding provider class - (`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `KubernetesProvider` or `M365Provider`) initialized with the + AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider: An instance of the corresponding provider class + (`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `IacProvider`, `KubernetesProvider` or `M365Provider`) initialized with the provider's secrets. """ prowler_provider = return_prowler_provider(provider) @@ -180,9 +196,24 @@ 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 + # TODO: review this + 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 76fa0b4911..1680be1d8a 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -213,6 +213,21 @@ from rest_framework_json_api import serializers }, "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"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index be3b46b45b..1c4022359b 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1223,6 +1223,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): 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: @@ -1312,6 +1314,14 @@ class GithubProviderSecret(serializers.Serializer): 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 AWSRoleAssumptionProviderSecret(serializers.Serializer): role_arn = serializers.CharField() external_id = serializers.CharField() diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 441b7a95d4..092a0f0d2f 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -100,6 +100,10 @@ COMPLIANCE_CLASS_MAP = { "github": [ (lambda name: name.startswith("cis_"), GithubCIS), ], + "iac": [ + # IaC provider doesn't have specific compliance frameworks yet + # Trivy handles its own compliance checks + ], } diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index d5cac1e89c..2745a82785 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -260,7 +260,7 @@ class Scan: try: # Initialize check_name for error handling check_name = None - + # Using SimpleNamespace to create a mocked object arguments = SimpleNamespace() @@ -286,17 +286,18 @@ class Scan: if self._provider.type == "iac": # IaC provider doesn't use regular checks, it runs Trivy directly from prowler.providers.iac.iac_provider import IacProvider + 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: # Extract metadata from CheckReportIAC check_metadata = report.check_metadata - + finding = Finding( provider=self._provider.type, check_id=check_metadata.CheckID, @@ -320,12 +321,12 @@ class Scan: compliance={}, # IaC doesn't have compliance mappings yet ) findings.append(finding) - + # 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())