diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000000..a888486d38 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "prowler-plugins", + "description": "Prowler Cloud Security for Claude Code", + "owner": { + "name": "Prowler", + "email": "support@prowler.com" + }, + "plugins": [ + { + "name": "prowler", + "source": "./claude_plugins/prowler", + "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.", + "category": "security", + "homepage": "https://prowler.com" + } + ] +} diff --git a/.github/workflows/api-container-build-push.yml b/.github/workflows/api-container-build-push.yml index 0c45471600..0ecf669fc1 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -133,11 +133,37 @@ jobs: with: persist-credentials: false - - name: Pin prowler SDK to latest master commit + - name: Pin prowler SDK to latest master commit and refresh lockfile if: github.event_name == 'push' run: | + set -e LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1) sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml + # Refresh api/uv.lock so it matches the pinned SHA above; the API + # Dockerfile runs `uv sync --locked`, which aborts on any drift + # between pyproject.toml and uv.lock. + pip install --no-cache-dir "uv==0.11.14" + (cd api && uv lock) + + - name: Pin prowler SDK to latest release branch (v5.Y) commit and refresh lockfile + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + set -e + # RELEASE_TAG looks like "5.14.0"; the SDK release branch is "v5.14". + VERSION="${RELEASE_TAG#v}" + VERSION_BRANCH="v$(echo "${VERSION}" | cut -d. -f1,2)" + LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git "refs/heads/${VERSION_BRANCH}" | cut -f1) + if [ -z "${LATEST_SHA}" ]; then + echo "ERROR: release branch ${VERSION_BRANCH} not found in prowler-cloud/prowler" + exit 1 + fi + echo "Pinning SDK to ${VERSION_BRANCH}@${LATEST_SHA}" + sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml + # Refresh api/uv.lock so it matches the pinned SHA above; the API + # Dockerfile runs `uv sync --locked`, which aborts on any drift + # between pyproject.toml and uv.lock. + pip install --no-cache-dir "uv==0.11.14" + (cd api && uv lock) - name: Login to DockerHub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 034261758c..11c2691fdf 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -139,6 +139,17 @@ jobs: sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + - name: Bump UI version (.env) run: | set -e @@ -240,6 +251,17 @@ jobs: sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + - name: Bump UI version (.env) run: | set -e @@ -341,6 +363,17 @@ jobs: sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + - name: Bump UI version (.env) run: | set -e diff --git a/.gitignore b/.gitignore index 9e0e4da849..4d73ffe990 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,6 @@ htmlcov/ **/mcp-config.json **/mcpServers.json .mcp/ -.mcp.json # AI Coding Assistants - Cursor .cursorignore diff --git a/Dockerfile b/Dockerfile index eb5debf888..44abbdf098 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,11 +76,11 @@ USER prowler WORKDIR /home/prowler # Copy necessary files -COPY prowler/ /home/prowler/prowler/ -COPY dashboard/ /home/prowler/dashboard/ -COPY pyproject.toml uv.lock /home/prowler/ -COPY README.md /home/prowler/ -COPY prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py +COPY --chown=prowler:prowler prowler/ /home/prowler/prowler/ +COPY --chown=prowler:prowler dashboard/ /home/prowler/dashboard/ +COPY --chown=prowler:prowler pyproject.toml uv.lock /home/prowler/ +COPY --chown=prowler:prowler README.md /home/prowler/ +COPY --chown=prowler:prowler prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py # Install Python dependencies ENV HOME='/home/prowler' @@ -89,7 +89,7 @@ ENV PATH="${HOME}/.local/bin:${PATH}" RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir uv==0.11.14 -RUN uv sync --compile-bytecode && \ +RUN uv sync --locked --compile-bytecode && \ rm -rf ~/.cache/uv # Install PowerShell modules diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index cba4f9c594..392b369206 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.29.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) + +--- + ## [1.28.0] (Prowler v5.27.0) ### 🚀 Added diff --git a/api/Dockerfile b/api/Dockerfile index e7b758f4b8..2851b0a35b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -89,7 +89,7 @@ WORKDIR /home/prowler # Ensure output directory exists RUN mkdir -p /tmp/prowler_api_output -COPY pyproject.toml uv.lock ./ +COPY --chown=prowler:prowler pyproject.toml uv.lock ./ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir uv==0.11.14 @@ -97,7 +97,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ ENV PATH="/home/prowler/.local/bin:$PATH" # Add `--no-install-project` to avoid installing the current project as a package -RUN uv sync --no-install-project && \ +RUN uv sync --locked --no-install-project && \ rm -rf ~/.cache/uv RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py diff --git a/api/src/backend/api/migrations/0093_okta_provider.py b/api/src/backend/api/migrations/0093_okta_provider.py new file mode 100644 index 0000000000..d3e4a9e397 --- /dev/null +++ b/api/src/backend/api/migrations/0093_okta_provider.py @@ -0,0 +1,41 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0092_findings_arrays_gin_index_parent"), + ] + + 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"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ("image", "Image"), + ("googleworkspace", "Google Workspace"), + ("vercel", "Vercel"), + ("okta", "Okta"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 17d38e82f0..3d9a26698e 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -296,6 +296,7 @@ class Provider(RowLevelSecurityProtectedModel): IMAGE = "image", _("Image") GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") VERCEL = "vercel", _("Vercel") + OKTA = "okta", _("Okta") @staticmethod def validate_aws_uid(value): @@ -354,6 +355,26 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_okta_uid(value): + if not re.match( + r"^[a-z0-9][a-z0-9-]*\.(" + r"okta\.com|oktapreview\.com|okta-emea\.com|" + r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com" + r")$", + value, + ): + raise ModelValidationError( + detail=( + "Okta provider ID must be a valid Okta-managed org domain " + "(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com " + "/ .okta-gov.com / .okta.mil / .okta-miltest.com / " + ".trex-govcloud.com), without scheme or path." + ), + code="okta-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_kubernetes_uid(value): if not re.match( @@ -480,6 +501,12 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() + if self.provider == self.ProviderChoices.OKTA and self.uid: + # Mirror the SDK, which lowercases the org domain before connecting. + # Without this the API would reject Acme.okta.com even though the + # SDK would accept it, and stored uids could disagree with the + # authenticated org domain. + self.uid = self.uid.strip().lower() getattr(self, f"validate_{self.provider}_uid")(self.uid) def save(self, *args, **kwargs): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 658119f1ba..892b425d4d 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -373,6 +373,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -389,6 +390,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -412,6 +414,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -430,6 +433,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1453,6 +1457,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1469,6 +1474,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -1491,6 +1497,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -1509,6 +1516,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1997,6 +2005,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2013,6 +2022,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2035,6 +2045,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2053,6 +2064,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -2584,6 +2596,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2600,6 +2613,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2622,6 +2636,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2640,6 +2655,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3134,6 +3150,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3150,6 +3167,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3173,6 +3191,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3191,6 +3210,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3740,6 +3760,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3756,6 +3777,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3779,6 +3801,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3797,6 +3820,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4254,6 +4278,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4270,6 +4295,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4293,6 +4319,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4311,6 +4338,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4766,6 +4794,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4782,6 +4811,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4805,6 +4835,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4823,6 +4854,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -5266,6 +5298,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5282,6 +5315,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -5305,6 +5339,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -5323,6 +5358,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7156,6 +7192,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7172,6 +7209,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7195,6 +7233,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7213,6 +7252,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7335,6 +7375,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7351,6 +7392,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7374,6 +7416,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7392,6 +7435,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7503,6 +7547,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7519,6 +7564,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7541,6 +7587,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7559,6 +7606,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7702,6 +7750,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7718,6 +7767,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7741,6 +7791,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7759,6 +7810,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7915,6 +7967,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7931,6 +7984,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7954,6 +8008,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7972,6 +8027,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8122,6 +8178,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8138,6 +8195,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8160,6 +8218,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8178,6 +8237,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -8370,6 +8430,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8386,6 +8447,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8409,6 +8471,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8427,6 +8490,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8548,6 +8612,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8564,6 +8629,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8587,6 +8653,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8605,6 +8672,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8750,6 +8818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8766,6 +8835,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8789,6 +8859,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8807,6 +8878,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9593,6 +9665,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9609,6 +9682,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider__in] schema: @@ -9632,6 +9706,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9650,6 +9725,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9673,6 +9749,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9689,6 +9766,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -9712,6 +9790,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9730,6 +9809,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -10400,6 +10480,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10416,6 +10497,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10439,6 +10521,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -10457,6 +10540,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -10951,6 +11035,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10967,6 +11052,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10990,6 +11076,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11008,6 +11095,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11315,6 +11403,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11331,6 +11420,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11354,6 +11444,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11372,6 +11463,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11685,6 +11777,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11701,6 +11794,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11724,6 +11818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11742,6 +11837,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -12580,6 +12676,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -12596,6 +12693,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -12619,6 +12717,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -12637,6 +12736,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -20115,6 +20215,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21127,6 +21244,7 @@ components: - image - googleworkspace - vercel + - okta type: string description: |- * `aws` - AWS @@ -21144,6 +21262,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta x-spec-enum-id: 91f917e0c3ab97e8 uid: type: string @@ -21265,6 +21384,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21285,6 +21405,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string title: Unique identifier for the provider, set by the provider @@ -21337,6 +21458,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21357,6 +21479,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string minLength: 3 @@ -22206,6 +22329,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -22631,6 +22771,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -23066,6 +23223,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 86da5567da..3a7f030a9c 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -31,6 +31,7 @@ from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -130,6 +131,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), (Provider.ProviderChoices.IMAGE.value, ImageProvider), (Provider.ProviderChoices.VERCEL.value, VercelProvider), + (Provider.ProviderChoices.OKTA.value, OktaProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -238,6 +240,31 @@ class TestProwlerProviderConnectionTest: raise_on_exception=False, ) + @patch("api.utils.return_prowler_provider") + def test_prowler_provider_connection_test_okta_provider( + self, mock_return_prowler_provider + ): + """Test connection test for Okta provider passes org domain and provider_id.""" + provider = MagicMock() + provider.uid = "acme.okta.com" + provider.provider = Provider.ProviderChoices.OKTA.value + provider.secret.secret = { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": ["okta.policies.read"], + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + okta_client_id="0oa123456789abcdef", + okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + okta_scopes=["okta.policies.read"], + okta_org_domain="acme.okta.com", + provider_id="acme.okta.com", + raise_on_exception=False, + ) + @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_image_provider_no_creds( self, mock_return_prowler_provider @@ -308,6 +335,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.VERCEL.value, {"team_id": "provider_uid"}, ), + ( + Provider.ProviderChoices.OKTA.value, + {"okta_org_domain": "provider_uid"}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 97796c97ca..008189e313 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1625,6 +1625,21 @@ class TestProviderViewSet: "uid": "C12", "alias": "Google Workspace Minimum Length", }, + { + "provider": "okta", + "uid": "acme.okta.com", + "alias": "Okta Org", + }, + { + "provider": "okta", + "uid": "agency.okta-gov.com", + "alias": "Okta Gov Org", + }, + { + "provider": "okta", + "uid": "agency.okta.mil", + "alias": "Okta Mil Org", + }, ] ), ) @@ -2143,6 +2158,24 @@ class TestProviderViewSet: "googleworkspace-uid", "uid", ), + ( + { + "provider": "okta", + "uid": "https://acme.okta.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), + ( + { + "provider": "okta", + "uid": "acme.example.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), ] ), ) @@ -2163,6 +2196,25 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) + @pytest.mark.parametrize( + "input_uid,stored_uid", + [ + ("Acme.okta.com", "acme.okta.com"), + (" ACME.OKTA.COM ", "acme.okta.com"), + ("Agency.Okta-Gov.com", "agency.okta-gov.com"), + ], + ) + def test_providers_create_okta_uid_normalized( + self, authenticated_client, input_uid, stored_uid + ): + response = authenticated_client.post( + reverse("provider-list"), + data={"provider": "okta", "uid": input_uid, "alias": "Okta"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.get().uid == stored_uid + def test_providers_partial_update(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture new_alias = "This is the new name" @@ -2320,17 +2372,17 @@ class TestProviderViewSet: ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 13), + ("inserted_at", TODAY, 14), ( "inserted_at.gte", "2024-01-01", - 13, + 14, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 13, + 14, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2963,6 +3015,19 @@ class TestProviderSecretViewSet: "api_token": "fake-vercel-api-token-for-testing", }, ), + # Okta with inline private key credentials + ( + Provider.ProviderChoices.OKTA.value, + ProviderSecret.TypeChoices.STATIC, + { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": [ + "okta.policies.read", + "okta.groups.read", + ], + }, + ), ], ) def test_provider_secrets_create_valid( @@ -3075,6 +3140,46 @@ class TestProviderSecretViewSet: == f"/data/attributes/{error_pointer}" ) + def test_provider_secrets_invalid_create_okta_missing_private_key( + self, + providers_fixture, + authenticated_client, + ): + okta_provider = next( + provider + for provider in providers_fixture + if provider.provider == Provider.ProviderChoices.OKTA.value + ) + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Okta Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "okta_client_id": "0oa123456789abcdef", + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(okta_provider.id)} + } + }, + } + } + + response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "required" + assert response.json()["errors"][0]["source"]["pointer"] == ( + "/data/attributes/secret/okta_private_key" + ) + def test_provider_secrets_partial_update( self, authenticated_client, provider_secret_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index b80d54b08a..8da8d91226 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from prowler.providers.mongodbatlas.mongodbatlas_provider import ( MongodbatlasProvider, ) + from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -93,6 +94,7 @@ def return_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -181,6 +183,10 @@ def return_prowler_provider( from prowler.providers.vercel.vercel_provider import VercelProvider prowler_provider = VercelProvider + case Provider.ProviderChoices.OKTA.value: + from prowler.providers.okta.okta_provider import OktaProvider + + prowler_provider = OktaProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -246,6 +252,11 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "team_id": provider.uid, } + elif provider.provider == Provider.ProviderChoices.OKTA.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -290,6 +301,7 @@ def initialize_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -351,6 +363,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**vercel_kwargs) + elif provider.provider == Provider.ProviderChoices.OKTA.value: + okta_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + "provider_id": provider.uid, + "raise_on_exception": False, + } + return prowler_provider.test_connection(**okta_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index f8d67f0e3b..0b8b4eacf4 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -404,6 +404,26 @@ from rest_framework_json_api import serializers }, "required": ["clouds_yaml_content", "clouds_yaml_cloud"], }, + { + "type": "object", + "title": "Okta OAuth Credentials", + "properties": { + "okta_client_id": { + "type": "string", + "description": "Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.", + }, + "okta_private_key": { + "type": "string", + "description": "PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.", + }, + "okta_scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.", + }, + }, + "required": ["okta_client_id", "okta_private_key"], + }, { "type": "object", "title": "Vercel API Token", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 51cb95fbff..18971452e8 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1543,6 +1543,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = GCPProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value: serializer = GoogleWorkspaceProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.OKTA.value: + serializer = OktaProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.IAC.value: @@ -1688,6 +1690,15 @@ class GoogleWorkspaceProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class OktaProviderSecret(serializers.Serializer): + okta_client_id = serializers.CharField() + okta_private_key = serializers.CharField() + okta_scopes = serializers.ListField(child=serializers.CharField(), required=False) + + class Meta: + resource_name = "provider-secrets" + + class MongoDBAtlasProviderSecret(serializers.Serializer): atlas_public_key = serializers.CharField() atlas_private_key = serializers.CharField() diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index c5228c77be..eb73b0c51f 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -571,6 +571,12 @@ def providers_fixture(tenants_fixture): alias="vercel_testing", tenant_id=tenant.id, ) + provider14 = Provider.objects.create( + provider="okta", + uid="acme.okta.com", + alias="okta_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -586,6 +592,7 @@ def providers_fixture(tenants_fixture): provider11, provider12, provider13, + provider14, ) diff --git a/api/uv.lock b/api/uv.lock index fe7cd1e89f..03993172d9 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4494,7 +4494,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.28.0" +version = "1.29.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/claude_plugins/prowler/.claude-plugin/plugin.json b/claude_plugins/prowler/.claude-plugin/plugin.json new file mode 100644 index 0000000000..7bf822e2e7 --- /dev/null +++ b/claude_plugins/prowler/.claude-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "prowler", + "version": "0.1.0", + "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.", + "author": { + "name": "Prowler", + "email": "support@prowler.com", + "url": "https://prowler.com" + }, + "homepage": "https://docs.prowler.com", + "repository": "https://github.com/prowler-cloud/prowler", + "license": "Apache-2.0", + "keywords": [ + "prowler", + "security", + "compliance", + "cloud-security", + "mcp" + ], + "userConfig": { + "api_key": { + "type": "string", + "title": "Prowler API key", + "description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.", + "sensitive": true, + "required": true + } + } +} diff --git a/claude_plugins/prowler/.mcp.json b/claude_plugins/prowler/.mcp.json new file mode 100644 index 0000000000..9788b6d982 --- /dev/null +++ b/claude_plugins/prowler/.mcp.json @@ -0,0 +1,9 @@ +{ + "prowler": { + "type": "http", + "url": "https://mcp.prowler.com/mcp", + "headers": { + "Authorization": "Bearer ${user_config.api_key}" + } + } +} diff --git a/claude_plugins/prowler/README.md b/claude_plugins/prowler/README.md new file mode 100644 index 0000000000..6e204fbe44 --- /dev/null +++ b/claude_plugins/prowler/README.md @@ -0,0 +1,80 @@ +# Prowler for Claude Code + +End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant. + +> **Preview**: this plugin is under active development. Report issues at or join the [Slack community](https://goto.prowler.com/slack). + +## Requirements + +- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in. +- A [Prowler Cloud](https://cloud.prowler.com) account (the free tier is enough to start). +- A Prowler API key — create one at . + +## Installation + +Inside a Claude Code session: + +```text +/plugin marketplace add prowler-cloud/prowler +/plugin install prowler@prowler-plugins +``` + +Or, if you already have the repo checked out locally: + +```text +/plugin marketplace add /absolute/path/to/prowler +/plugin install prowler@prowler-plugins +``` + +## Configuration + +On first install, Claude Code prompts for your **Prowler API key**. It is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. + +To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again. + +## Verify the install + +In a Claude Code session: + +```text +/mcp → "prowler" appears as a connected server +/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage +``` + +If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. + +## Usage + +Open a conversation that mentions the framework you want to comply with. Examples: + +- *"Make my AWS production account compliant with CIS 4.0."* +- *"Make my current Terraform project compliant with the Prowler ThreatScore Compliance Framework based on the latest scan results."* +- *"Help me get to 100% on PCI-DSS for this GCP project."* + +You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: + +- **Claude-assisted** (default). Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. +- **Claude autonomous**. Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. + +Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. + +## Uninstalling + +```text +/plugin uninstall prowler@prowler-plugins +/plugin marketplace remove prowler-plugins +``` + +The stored API key is removed automatically. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. | +| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. | +| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at . | + +## License + +Apache 2.0 — see [LICENSE](../../LICENSE). diff --git a/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md new file mode 100644 index 0000000000..1af29f82b9 --- /dev/null +++ b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md @@ -0,0 +1,199 @@ +--- +name: framework-compliance-triage +description: Make a cloud account compliant with a security or industry framework using Prowler Cloud. +--- + +# Framework compliance + +Iterative, interactive flow that takes a cloud account through setup, reporting, and remediation until it complies with the chosen security or industry framework. + +## Checkpoints + +This skill uses **checkpoints** to mark moments where you must stop, post a clear question or summary to the user, and wait for the reply before continuing. Each checkpoint is rendered like this: + +> **Checkpoint — ** +> +> What to present, and what to wait for. + +Treat every checkpoint as a hard stop: + +- Do not skip a checkpoint because the user previously said "go ahead", "just do it", or similar. Confirmations are scoped to a single checkpoint and do not transfer to later ones. +- Do not bundle two checkpoints into one message. Post one, wait for the reply, then continue. +- Do not infer the user's answer from context or proceed on silence. Ask explicitly and wait. +- If a checkpoint is conditional (e.g. only fires when multiple accounts exist), evaluate the condition first; if it does not apply, continue without prompting. +- If the user's initial message already answers the question a checkpoint asks (e.g. "make my AWS subscription compliant with CIS using Terraform autonomously"), treat the checkpoint as satisfied for the parts they covered, and only ask for what is still missing. + +## 1. Initial Prowler Cloud setup + +> **Checkpoint — Provider and framework selection** +> +> If the user has not already specified both the provider and the framework, ask explicitly and wait for the answer. If they have specified them in their opening message, skip this checkpoint. + +Confirm both are supported by the Prowler Hub MCP: + +- Enumerate supported providers with `prowler_hub_list_providers`. +- Enumerate frameworks for the chosen provider with `prowler_hub_list_compliances`, passing the provider `id` as the only element of the `provider` input list. + +If the framework is not supported, tell the user, suggest they request it or contribute it themselves, and end the flow. Otherwise continue. + +### 1.1 Connect to Prowler Cloud + +Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account. +For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation. + +### 1.2 Verify the provider is configured (or configure it) + +Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found: + +- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`. +- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`. +- **Provider present and configured.** Continue. + +> **Checkpoint — Account selection** *(conditional: more than one account of the chosen provider is configured)* +> +> List the accounts with helpful detail (account name, uid, last scan date) and ask which one to use. Wait for the answer. If only one account exists, skip this checkpoint and use it. + +### 1.3 Review compliance report for the provider account + +The flow needs at least one completed scan with a compliance report available. + +Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section. + +If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress. + +> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)* +> +> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer. + +If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress. + +When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results. + +## 2. Compliance report + +Every iteration of the remediation loop reads and writes a single markdown file per provider account and framework, stored at `${CLAUDE_PROJECT_DIR}/.prowler/compliance--.md`. Sanitize `` to `[a-zA-Z0-9_-]` by replacing anything else with `-`. Create `.prowler/` if missing. + +Across iterations, edit only: status tags on failed requirements and their findings, the per-requirement `Fix plan` / `Fix applied` sub-bullets added during sections 3.3–3.4, the **Global remediation approach** block, and the **Activity log** (append-only, newest on top). Requirement descriptions, finding IDs, and the entire **Manual review requirements** section are read-only after first render. + +Status taxonomy for failed requirements and their findings: + +- `[FAIL]` — failing in the latest scan. +- `[IN PROGRESS]` — picked up by section 3.3. +- `[FIXED-UNVERIFIED]` — remediation applied; not yet confirmed. +- `[PASS]` — passing in the latest scan (set when a rescan in section 3.5 confirms the fix). +- `[SKIPPED]` — user explicitly deferred. + +### Report template + +A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response): + +````markdown +# Compliance report: + +**Provider account**: +**Scan ID**: +**Generated**: +**Last update**: +**Status**: / passing (%) · failing · manual review + +## Global remediation approach + +- **Primary tool**: _Terraform | Azure CLI | AWS CLI | web console | mixed_ +- **Mode**: _Claude autonomous | Claude-assisted_ +- **Notes**: + +## Activity log +- — Report initialized from scan ``. + +## Failed requirements + +### — [FAIL] +**Description**: +**Findings** (): +- [FAIL] `` + +## Manual review requirements +- **** — [PENDING]: +```` + +### 2.1 Generate or refresh the report + +Resolve the report path for the current `compliance_id` and provider account. + +If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log. + +If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat. + +> **Checkpoint — Report refresh** *(conditional: the file's `Scan ID` differs from the current target scan)* +> +> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer. + +On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change. + +Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list. + +## 3. Remediation loop + +### 3.1 Define the global remediation approach + +Two modes are available: + +- **Claude-assisted** (default when the user has not specified): per-requirement confirmation. For each requirement Claude shows the target resource, exact commands, side effects, and reversibility, then waits for explicit go-ahead before applying. +- **Claude autonomous**: no per-requirement gate, but Claude still presents one batch-level fix plan up front (§3.2) and waits for a single confirmation, and pauses if a finding looks not applicable, requires a paid feature, or has wide blast radius (breaks dev workflow, forces collaborator changes, is hard to reverse). + +If the user phrases their request as "just do it" or similar, treat that as autonomous **with** the batch-plan confirmation still required — the confirmation is a property of the skill, not the user's verbosity preference. + +> **Checkpoint — Global remediation approach** +> +> Ask the user which tool to use for fixes (Terraform, gh / az / aws CLI, web console, mixed...) and which mode to operate in. Wait for the answer before continuing. This checkpoint is non-negotiable: never assume a default tool, and never assume autonomous mode. + +Once answered, write the values into the **Global remediation approach** block of the report file. + +> **Checkpoint — Overwriting an existing approach** *(conditional: the block is already populated from a previous session)* +> +> Show the previous values and the new ones, and ask the user to confirm before overwriting. Wait for the answer. + +### 3.2 Present the batch fix plan *(autonomous mode only)* + +In **assisted** mode, skip this section — the per-requirement gate in §3.3 confirms each fix as it comes up. Only run §3.2 in **autonomous** mode, where the loop will otherwise apply fixes without further input. + +Before touching anything, post a single chat summary covering every `[FAIL]` requirement: + +- Group findings that share a fix (e.g. ten branch-protection requirements satisfied by one PUT call → present as one group). +- For each group: target resource, exact tool calls, side effects, reversibility. +- Call out findings that look **not applicable** to this target (e.g. an Organization-only check evaluated against a User account, a feature gated by a paid plan, a resource type the user doesn't have) and propose `[SKIPPED]` with the reason. +- Call out findings that require manual user action Claude cannot perform. + +> **Checkpoint — Batch fix plan approval** *(conditional: autonomous mode)* +> +> Post the grouped plan and wait for explicit confirmation. Do not start any fix before the user replies. + +Once approved, the loop proceeds through the batch without further prompts unless something deviates from the approved plan. + +### 3.3 Pick the first FAIL requirement and inspect its findings + +Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done. + +Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding. + +If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2. + +> **Checkpoint — Per-requirement approval** *(conditional: assisted mode)* +> +> Post the per-requirement plan in chat — resource, command, side effects, reversibility — and wait for confirmation before moving to §3.4. In **autonomous** mode, post the plan for transparency but proceed unless it deviates from the batch plan agreed in §3.2. + +### 3.4 Diagnose, fix, verify + +Read the remediation guidance returned in §3.3, identify the root cause, and apply the fix using the tool defined in the **Global remediation approach** block. After applying, verify via the same tool that applied the fix or via a provider API call when applicable. If the re-read shows the change did not land, leave the status at `[IN PROGRESS]`, surface the error to the user, and stop the loop for this requirement. + +When the change is in place, append a `**Fix applied**: ` sub-bullet to the requirement, move each fixed finding to `[FIXED-UNVERIFIED]`, and add one activity-log entry describing the change. If no programmatic verification was possible (e.g. web console action), note in the activity log that confirmation depends on the rescan in §3.5. + +### 3.5 Loop + +Move to the next `[FAIL]` requirement and repeat from section 3.3. + +> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)* +> +> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer. + +On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log. diff --git a/docs/docs.json b/docs/docs.json index e39ccb67ea..8c3c79e8be 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -73,6 +73,12 @@ "getting-started/products/prowler-lighthouse-ai" ] }, + { + "group": "Prowler for Claude Code", + "pages": [ + "getting-started/products/prowler-claude-code-plugin" + ] + }, { "group": "Prowler MCP Server", "pages": [ diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx new file mode 100644 index 0000000000..e3c11ec810 --- /dev/null +++ b/docs/getting-started/products/prowler-claude-code-plugin.mdx @@ -0,0 +1,101 @@ +--- +title: 'Prowler for Claude Code' +--- + +End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant. + + +**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback. + + +## Requirements + + + + Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code). + + + The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + + + Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile). + + + +## Installation + + + + Inside a Claude Code session: + + ```text + /plugin marketplace add prowler-cloud/prowler + /plugin install prowler@prowler-plugins + ``` + + + If you already have the repository checked out: + + ```text + /plugin marketplace add /absolute/path/to/prowler + /plugin install prowler@prowler-plugins + ``` + + + +## Configuration + +On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. + + +To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again. + + +## Verify the installation + +In a Claude Code session: + +```text +/mcp → "prowler" appears as a connected server +/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage +``` + +If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. + +## Usage + +Open a conversation that mentions the framework you want to comply with. Examples: + +- *"Make my AWS production account compliant with CIS 4.0."* +- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."* +- *"Help me get to 100% on PCI-DSS for this GCP project."* + +You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: + + + + Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. + + + Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. + + + +Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. + +## Uninstalling + +```text +/plugin uninstall prowler@prowler-plugins +/plugin marketplace remove prowler-plugins +``` + +The stored API key is removed automatically. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. | +| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. | +| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). | diff --git a/docs/introduction.mdx b/docs/introduction.mdx index c8dc5c649e..dd05e3dbdb 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,10 +32,10 @@ Prowler supports a wide range of providers organized by category: | [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | | [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | | [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | -| **NHN** | Unofficial | Tenants | CLI | +| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI | | [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | -| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) [Contact us](https://prowler.com/contact) | Unofficial | Organizations | CLI | +| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI | ### Infrastructure as Code Providers diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index ad0b1fcce1..23d8b8d5a2 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -173,6 +173,215 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service LIMIT 25 ``` +### Advanced Attack Path Scenarios + +The following scenarios show how to compose graph traversals into real attack-path stories. Each query can be pasted directly into the custom query box: the API auto-scopes them to the selected provider and injects tenant/provider isolation, so there is no need to include account identifiers or `$provider_uid` in the text. All queries are openCypher v9 (Neo4j and Neptune compatible). + +#### 1. Live attacker on the box that owns the keys + +**Query story:** Finds an internet-exposed EC2 under an active GuardDuty SSH brute-force whose instance role can assume a higher-privileged role that can read a sensitive S3 bucket. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p0 = (gd:GuardDutyFinding)-[:AFFECTS]->(ec2) +MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) +MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) +MATCH p3 = (high)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +MATCH path_s3 = (acct)--(s3:S3Bucket) +WHERE high <> low + AND stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) STARTS WITH 's3:getobject' + OR toLower(a) STARTS WITH 's3:listbucket' + OR toLower(a) IN ['s3:*'] + ]) > 0 + AND size([r IN stmt.resource WHERE + r CONTAINS s3.name + ]) > 0 +RETURN path_net, path_ec2, p0, p1, p2, p3, path_s3 +``` + +**How it's built:** + +- `path_ec2` anchors the graph on the account node and its internet-exposed EC2 instance, via a real account-to-resource edge. This is the visible spine that keeps everything connected. +- `p0` ties a `GuardDutyFinding` to that instance through the `AFFECTS` edge (the live SSH brute-force alert). +- `p1` walks the real graph edges from the instance to its instance profile to the role it runs as. +- `p2` follows the `STS_ASSUMEROLE_ALLOW` edge to the higher-privileged role the low role can assume. It is undirected so it works regardless of how the assume edge was ingested. `high <> low` stops a role matching itself. +- `p3` walks that role into its policy and policy statement. +- `path_net` is the optional `Internet -[:CAN_ACCESS]-> instance` edge. It makes "from the internet" literal on screen. Optional so a missing `Internet` node never breaks the query live. +- `path_s3` connects the sensitive bucket to the same account node, so it draws connected instead of floating. There is no physical edge from a role to a bucket; the grant is logical, enforced in the `WHERE`: the statement must allow an S3 read action (list comprehension over the `action` array) and its resource must cover the bucket (`CONTAINS s3.name`). The account is the shared hub; the bucket hanging off it next to the role chain is the teaching moment — the access exists only in IAM. + +#### 2. Who can read the crown jewels + +**Query story:** The sensitive bucket from the previous scenario seen from the data side: every role whose IAM policy can read it, regardless of how the role is reached. + +```cypher +MATCH (s3:S3Bucket) +WHERE toLower(s3.name) CONTAINS 'sensitive' +MATCH (role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +WHERE stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) STARTS WITH 's3:get' + OR toLower(a) STARTS WITH 's3:list' + OR toLower(a) IN ['s3:*'] + ]) > 0 + AND size([r IN stmt.resource WHERE + r CONTAINS s3.name + ]) > 0 +WITH DISTINCT s3, role +LIMIT 25 +MATCH path_s3 = (acct:AWSAccount)--(s3) +MATCH path_role = (acct)--(role) +RETURN path_s3, path_role +``` + +**How it's built:** data-centric, not attacker-centric — the same bucket the previous kill chain exfiltrates, approached from the other direction. + +- The `S3Bucket` is bound first by name (one node), so everything else filters against it. +- `(role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)` reaches statements only *through a role*, never via a global statement scan. A blanket `AWSPolicyStatement` scan also hits resource-policy statements whose shape differs and makes the list comprehension fail outright. +- The `WHERE` filters in place: an S3 read action plus a resource that names that bucket. +- `WITH DISTINCT s3, role LIMIT 25` collapses undirected-traversal duplicates and hard-caps the result. +- `path_s3` and `path_role` attach the account hubs only after the cap, against at most 25 rows, so the bucket and role(s) draw connected through the account instead of floating. +- No internet or EC2 here; this answers "who has the keys" instead of "how would an attacker get in." + +#### 3. Lateral reach from an internet-exposed instance + +**Query story:** The wide-angle view of the live-attacker scenario: every internet-exposed EC2, the role it runs as, and every role that role can assume. The first scenario is one specific exfiltration path inside this reach, under live attack. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) +MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +WHERE high <> low +RETURN path_net, path_ec2, p1, p2 +``` + +**How it's built:** widens the lens instead of filtering down. It stops at the assume-role hop and shows every role reachable from any internet-exposed instance, without filtering down to a specific S3 leg. + +- `path_ec2` is the account-to-instance spine. +- `p1` walks to the instance role. +- `p2` fans out to every role that role can assume. +- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge. +- The first scenario is the specific exfiltration path under live attack; this is the broader privilege reach an attacker inherits the moment they land on the box. + +#### 4. Role-chain privilege escalation + +**Query story:** A pure-IAM escalation, no compromised instance: a role that can assume a second role whose policy lets it assume a third, admin-level role. + +```cypher +MATCH path_root = (acct:AWSAccount)--(r1:AWSRole) +MATCH p1 = (r1)-[:STS_ASSUMEROLE_ALLOW]-(r2:AWSRole) +MATCH p2 = (r2)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +MATCH path_admin = (acct)--(admin:AWSRole) +WHERE r1 <> r2 AND r1 <> admin AND r2 <> admin + AND stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) IN ['sts:*', 'sts:assumerole'] + ]) > 0 + AND size([res IN stmt.resource WHERE + res CONTAINS admin.name + ]) > 0 +RETURN path_root, p1, p2, path_admin +``` + +**How it's built:** + +- `path_root` anchors role 1 to the account node, the spine that keeps the picture connected. +- `p1` is the one real assume edge in the chain (role 1 to role 2). +- `p2` walks role 2 into its policy and statement. +- `path_admin` connects the target admin role to the same account node so it draws connected. The third hop is not a graph edge: it exists only as `sts:AssumeRole` on that role's ARN inside the statement. The query proves it the same way the first scenario proves S3 access — the statement action must include an assume-role action and its resource list must reference the admin role's name. +- The three `<>` guards stop a role matching itself at any position. + +#### 5. External identity trust map + +**Query story:** Finds external identity providers (SSO, GitHub, GitLab, Terraform Cloud) and the AWS roles they are trusted to assume. + +```cypher +MATCH p = (role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(idp:AWSPrincipal) +WHERE idp.arn CONTAINS 'saml-provider' + OR idp.arn CONTAINS 'oidc-provider' +MATCH path_role = (acct:AWSAccount)--(role) +RETURN p, path_role +``` + +**How it's built:** federated principals are stored as `AWSPrincipal` nodes whose ARN contains `saml-provider` (SSO) or `oidc-provider` (GitHub, GitLab, Terraform Cloud). + +- `p` matches the trust edge undirected. It is written `(AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(AWSPrincipal)`, role to principal, so a directed `principal -> role` match returns nothing; undirected matches regardless of ingest direction. +- The `WHERE` keeps only SAML or OIDC providers, drawing a fan-out from each external identity provider to every role it can assume (including reserved SSO admin roles). +- `path_role` ties every trusted role to the account node so the provider stars share one spine instead of drawing as separate islands. + +#### 6. Federated SSO roles flagged as admin or privesc + +**Query story:** The dangerous subset of the trust map above — externally-federated SSO roles that Prowler also flags for AdministratorAccess or privilege escalation. + +```cypher +MATCH (idp:AWSPrincipal)-[:TRUSTS_AWS_PRINCIPAL]-(role:AWSRole) +WHERE idp.arn CONTAINS 'saml-provider' + OR idp.arn CONTAINS 'oidc-provider' +MATCH (role)-[:HAS_FINDING]-(pf:ProwlerFinding) +WHERE pf.status = 'FAIL' + AND pf.check_id IN [ + 'iam_inline_policy_allows_privilege_escalation', + 'iam_role_administratoraccess_policy', + 'iam_inline_policy_no_administrative_privileges', + 'iam_user_administrator_access_policy' + ] +WITH DISTINCT idp, role, pf +LIMIT 60 +MATCH path_root = (acct:AWSAccount)--(role) +MATCH p_trust = (idp)-[:TRUSTS_AWS_PRINCIPAL]-(role) +MATCH p_find = (role)-[:HAS_FINDING]-(pf) +RETURN path_root, p_trust, p_find +``` + +**How it's built:** a plain "list every flagged identity" query is a wide fan that draws as a column, and `ProwlerFinding` nodes accumulate across scans with no scan filter available in custom queries. + +- The first MATCH plus `WHERE` keeps only roles trusted by a SAML or OIDC provider (trust edge undirected, so direction does not matter). +- The second MATCH plus `check_id IN [...]` keeps only those carrying one of the four privilege-escalation or admin checks. +- `WITH DISTINCT ... LIMIT 60` collapses duplicate finding nodes and hard-caps the result. +- `p_trust`, `p_find`, and `path_root` draw it connected three ways: provider to role through the trust edge, role to its finding, and role to the account. +- The previous scenario shows who can walk in; this shows which of those roles Prowler already flags as over-privileged. + +#### 7. World-readable S3 buckets + +**Query story:** Unlike the IAM-gated sensitive bucket in scenarios 1 and 2, these buckets are open to anyone on the internet with no credentials at all. + +```cypher +MATCH path_s3 = (acct:AWSAccount)--(s3:S3Bucket) +WHERE s3.anonymous_access = true +OPTIONAL MATCH p = (s3)--(stmt:S3PolicyStatement) +RETURN path_s3, p +``` + +**How it's built:** the counterpoint to scenarios 1 and 2 — there the sensitive bucket is reachable only through an IAM role chain; here the bucket needs no role at all. + +- `path_s3` connects each public bucket to its account node so they draw connected. Cartography sets `anonymous_access = true` when a bucket's policy or ACL allows public access. +- `p` is an optional match that pulls in the `S3PolicyStatement` granting the access where one exists, so the public grant is visible next to the bucket. Buckets that are public via ACL only still show, connected to the account. + +#### 8. Internet exposure surface + +**Query story:** The raw external attack surface behind scenarios 1 and 3: every internet-exposed EC2 instance with its security groups and the exact inbound ports left open. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p1 = (ec2)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +OPTIONAL MATCH p2 = (ec2)-[:INSTANCE_PROFILE]->(:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(:AWSRole) +RETURN path_net, path_ec2, p1, p2 +``` + +**How it's built:** `exposed_internet = true` is Cartography's computed reachability flag. + +- `path_ec2` hubs all exposed instances on the account node so they draw as one picture. +- `p1` joins each instance to its security groups and inbound rules so the open ports are on screen. +- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge so the external reachability is explicit. +- `p2` optionally adds the instance role, which connects this surface view back to the kill chains in scenarios 1 and 3. + ### Tips for Writing Queries - Start small with `LIMIT` to inspect the shape of the data before broadening the pattern. diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx index 26f73000b3..b353bc85c5 100644 --- a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx +++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx @@ -67,7 +67,14 @@ The currently active organization is indicated by an **Active** badge. Switching ## Editing an Organization Name -Organization owners with the **Manage Account** permission can rename an organization: +Renaming an organization requires **both** of the following conditions to be met: + +* The user's **membership role** in that organization must be `owner` (visible as the `owner` badge in the Organizations card). +* The user must have a role that grants the **Manage Account** permission. + +Users who only meet one of the two conditions will not see the **Edit** button. For example, a user whose membership role is `member` will not see the **Edit** button even if their role grants `Manage Account`. + +To rename an organization: 1. Navigate to the **Profile** page. @@ -175,6 +182,6 @@ If the expelled organization was the user's **only** organization, the account i | View organizations | Any authenticated user | | Create an organization | Any authenticated user | | Switch organizations | Any authenticated user | -| Edit organization name | Organization owner with **Manage Account** permission | -| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization | +| Edit organization name | Membership role `owner` **and** a role with **Manage Account** permission | +| Delete an organization | Membership role `owner` **and** a role with **Manage Account** permission; must belong to more than one organization | | Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves | diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000000..5d029efdf6 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,30 @@ +# osv-scanner per-vulnerability ignore list. +# +# Each [[IgnoredVulns]] entry must include a `reason` explaining why the +# finding is accepted and an `ignoreUntil` date so the suppression auto-expires +# and gets re-evaluated. See https://github.com/google/osv-scanner for the +# config schema. + +[[IgnoredVulns]] +id = "PYSEC-2025-183" +ignoreUntil = 2026-08-20T00:00:00Z +reason = """ +CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes +weak encryption, but the underlying issue is that callers may pick a short +HMAC secret — key-length enforcement is the application's responsibility, not +a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and +enforce key strength in our own auth code, so this advisory does not apply. +Re-evaluate when a non-disputed advisory or upstream fix lands. +""" + +[[IgnoredVulns]] +id = "PYSEC-2026-89" +ignoreUntil = 2026-08-20T00:00:00Z +reason = """ +False positive caused by a malformed PYSEC record. The equivalent GitHub +Security Advisory (GHSA-5wmx-573v-2qwq) for CVE-2025-69534 declares the issue +fixed in markdown 3.8.1. We are on markdown==3.10.2 (latest release, includes +the fix), but the PYSEC entry's range is [{introduced: "0"}, {}] with no +closing "fixed" event, so osv-scanner flags every version. There is no newer +release to upgrade to. Re-evaluate once the PYSEC record is corrected upstream. +""" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dd0648c5ec..2730106018 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,8 +8,15 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232) - `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023) +- Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186) +- `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923) +- `sagemaker_models_registry_in_use` check for AWS provider, verifying that at least one SageMaker Model Package Group has an approved model package to enforce ML governance workflows [(#11196)](https://github.com/prowler-cloud/prowler/pull/11196) - `signon_dod_warning_banner_configured`, `signon_global_session_lifetime_18h`, `signon_global_session_cookies_not_persistent` and `signon_global_session_policy_network_zone_enforced` checks for Okta provider [(#11224)](https://github.com/prowler-cloud/prowler/pull/11224) +### 🔄 Changed + +- `OktaProvider.test_connection` accepts an optional `provider_id` (org domain) and raises `OktaInvalidProviderIdError` (14007) when it doesn't match the authenticated org — guards against stored UID drifting from the credentials' org [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) + --- ## [5.27.1] (Prowler UNRELEASED) diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 7e79427747..95e07874af 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -1222,7 +1222,9 @@ { "Id": "3.1.6.1", "Description": "Ensure accessing groups from outside this organization is set to private", - "Checks": [], + "Checks": [ + "groups_external_access_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1243,7 +1245,9 @@ { "Id": "3.1.6.2", "Description": "Ensure creating groups is restricted", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1264,7 +1268,9 @@ { "Id": "3.1.6.3", "Description": "Ensure default for permission to view conversations is restricted", - "Checks": [], + "Checks": [ + "groups_view_conversations_restricted" + ], "Attributes": [ { "Section": "3 Apps", diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index 21c4b5d815..b0ba52cefe 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -1626,7 +1626,9 @@ { "Id": "GWS.GROUPS.1.1", "Description": "Group access from outside the organization SHALL be disabled unless explicitly granted by the group owner", - "Checks": [], + "Checks": [ + "groups_external_access_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1639,7 +1641,9 @@ { "Id": "GWS.GROUPS.1.2", "Description": "Group owners' ability to add external members to groups SHOULD be disabled unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1652,7 +1656,9 @@ { "Id": "GWS.GROUPS.1.3", "Description": "Group owners' ability to allow posting to a group by an external, non-group member SHOULD be disabled unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1665,7 +1671,9 @@ { "Id": "GWS.GROUPS.2.1", "Description": "Group creation SHOULD be restricted to admins within the organization unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1678,7 +1686,9 @@ { "Id": "GWS.GROUPS.3.1", "Description": "The default permission to view conversations SHOULD be set to All Group Members", - "Checks": [], + "Checks": [ + "groups_view_conversations_restricted" + ], "Attributes": [ { "Section": "Groups", diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json new file mode 100644 index 0000000000..fe9bd5db95 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_models_registry_in_use", + "CheckTitle": "Amazon SageMaker Model Registry should have at least one approved model package", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Model Registry** is evaluated to verify that at least one Model Package Group exists and contains at least one model package with **ModelApprovalStatus = Approved**. This confirms that the ML governance workflow (register → review → approve → deploy) is actively in use.", + "Risk": "An empty Model Registry, or one with no approved packages, indicates that models are being deployed outside any review process. This breaks provenance and accountability for production ML workloads, making it impossible to enforce governance controls such as auditing, versioning, and approval workflows.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry-approve.html", + "https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackageGroups.html", + "https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackages.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker list-model-package-groups\naws sagemaker list-model-packages --model-package-group-name \naws sagemaker update-model-package --model-package-arn --model-approval-status Approved", + "NativeIaC": "", + "Other": "1. In the AWS console, navigate to SageMaker > Models > Model Registry.\n2. Create a Model Package Group if none exists.\n3. Register a model version in the group.\n4. Review and approve at least one model package by setting its approval status to Approved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register all production models in the **SageMaker Model Registry** and enforce an approval workflow before deployment. Ensure at least one model package per group reaches **Approved** status. Use **IAM policies** to restrict who can approve model packages and integrate with **CI/CD pipelines** to automate registration.", + "Url": "https://hub.prowler.com/check/sagemaker_models_registry_in_use" + } + }, + "Categories": [ + "gen-ai", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py new file mode 100644 index 0000000000..5c7ff31fa5 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_models_registry_in_use(Check): + """Ensure that SageMaker Model Registry has at least one approved model package.""" + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports indicating whether the SageMaker Model Registry + in each region contains at least one approved model package. + """ + findings = [] + for registry in sagemaker_client.sagemaker_model_registries: + report = Check_Report_AWS(metadata=self.metadata(), resource=registry) + if not registry.has_groups: + report.status = "FAIL" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has no Model Package Groups." + elif registry.has_approved_packages: + report.status = "PASS" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has at least one approved model package." + else: + report.status = "FAIL" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has Model Package Groups but no approved model packages." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 8577dfe08e..0f73062452 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -17,6 +17,7 @@ class SageMaker(AWSService): self.sagemaker_training_jobs = [] self.sagemaker_domains = [] self.endpoint_configs = {} + self.sagemaker_model_registries = [] # Retrieve resources concurrently self.__threading_call__(self._list_notebook_instances) @@ -24,6 +25,7 @@ class SageMaker(AWSService): self.__threading_call__(self._list_training_jobs) self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) + self.__threading_call__(self._list_model_package_groups) # Describe resources concurrently self.__threading_call__(self._describe_model, self.sagemaker_models) @@ -207,6 +209,71 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_model_package_groups(self, regional_client): + logger.info("SageMaker - listing model package groups...") + registry_arn = self.get_unknown_arn( + region=regional_client.region, + resource_type="model-registry", + ) + has_groups = False + has_approved = False + try: + paginator = regional_client.get_paginator("list_model_package_groups") + for page in paginator.paginate(): + for group in page["ModelPackageGroupSummaryList"]: + has_groups = True + if not has_approved: + group_name = group["ModelPackageGroupName"] + try: + pkg_paginator = regional_client.get_paginator( + "list_model_packages" + ) + for pkg_page in pkg_paginator.paginate( + ModelPackageGroupName=group_name, + ModelApprovalStatus="Approved", + ): + if pkg_page["ModelPackageSummaryList"]: + has_approved = True + break + except ClientError as pkg_error: + if pkg_error.response["Error"]["Code"] in ( + "AccessDeniedException", + "UnrecognizedClientException", + ): + raise + logger.error( + f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}" + ) + except Exception as pkg_error: + logger.error( + f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}" + ) + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "UnrecognizedClientException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.sagemaker_model_registries.append( + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=regional_client.region, + has_groups=has_groups, + has_approved_packages=has_approved, + ) + ) + def _list_tags_for_resource(self, resource): """ Lists tags for a specific SageMaker resource. @@ -364,3 +431,13 @@ class EndpointConfig(BaseModel): arn: str production_variants: list[ProductionVariant] = [] tags: Optional[list] = [] + + +class ModelRegistry(BaseModel): + """Represents the SageMaker Model Registry state for a specific region.""" + + name: str + arn: str + region: str + has_groups: bool = False + has_approved_packages: bool = False diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json new file mode 100644 index 0000000000..83353b0caa --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "ses_identity_dkim_enabled", + "CheckTitle": "SES identity has DKIM signing enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ses", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "messaging", + "Description": "**Amazon SES identities** are evaluated for **DKIM (DomainKeys Identified Mail)** signing enabled and verified. DKIM adds a cryptographic signature to outgoing emails, allowing recipients to verify that the email was sent by the domain owner and was not altered in transit.", + "Risk": "Without DKIM signing, emails sent from SES identities are vulnerable to **spoofing and tampering**. Attackers can forge emails that appear to come from your domain, leading to phishing attacks, brand impersonation, and loss of email deliverability. Email providers are more likely to reject or mark unsigned emails as spam, impacting business communication and reputation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html", + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sesv2 put-email-identity-dkim-signing-attributes --email-identity --signing-attributes-origin AWS_SES", + "NativeIaC": "", + "Other": "1. In the AWS Console, go to Simple Email Service (SES)\n2. Open Verified identities and select the affected identity\n3. Click the Authentication tab\n4. Under DKIM, click Edit\n5. Enable DKIM signatures and select 'Provide DKIM authentication token (Easy DKIM)'\n6. Save changes and add the provided CNAME records to your DNS provider", + "Terraform": "```hcl\nresource \"aws_ses_domain_dkim\" \"\" {\n domain = \"\"\n}\n\n# Add the CNAME records to Route53 (or your DNS provider)\nresource \"aws_route53_record\" \"_dkim\" {\n count = 3\n zone_id = \"\"\n name = \"${aws_ses_domain_dkim..dkim_tokens[count.index]}._domainkey.\"\n type = \"CNAME\"\n ttl = 600\n records = [\"${aws_ses_domain_dkim..dkim_tokens[count.index]}.dkim.amazonses.com\"]\n}\n```" + }, + "Recommendation": { + "Text": "Enable **DKIM signing** for all SES identities and ensure the DKIM status is **SUCCESS**. Add the required CNAME records to your DNS provider to complete verification. Combine DKIM with **SPF** and **DMARC** for comprehensive email authentication following **defense in depth** principles. Monitor DKIM status regularly and rotate DKIM keys as recommended by AWS.", + "Url": "https://hub.prowler.com/check/ses_identity_dkim_enabled" + } + }, + "Categories": [ + "identity-access", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py new file mode 100644 index 0000000000..2654010e28 --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ses.ses_client import ses_client + + +class ses_identity_dkim_enabled(Check): + def execute(self): + findings = [] + for identity in ses_client.email_identities.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=identity) + if identity.dkim_status == "SUCCESS" and identity.dkim_signing_enabled: + report.status = "PASS" + report.status_extended = f"SES identity {identity.name} has DKIM signing enabled and verified." + elif identity.dkim_status in ( + "PENDING", + "NOT_STARTED", + "TEMPORARY_FAILURE", + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing not verified (status: {identity.dkim_status})." + elif identity.dkim_status == "FAILED": + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing failed verification." + elif ( + identity.dkim_status == "SUCCESS" and not identity.dkim_signing_enabled + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM verified but signing is disabled." + else: + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} does not have DKIM signing configured." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ses/ses_service.py b/prowler/providers/aws/services/ses/ses_service.py index ff19f829e6..7d0feb8f54 100644 --- a/prowler/providers/aws/services/ses/ses_service.py +++ b/prowler/providers/aws/services/ses/ses_service.py @@ -46,9 +46,15 @@ class SES(AWSService): identity_attributes = regional_client.get_email_identity( EmailIdentity=identity.name ) - for _, content in identity_attributes["Policies"].items(): + for _, content in identity_attributes.get("Policies", {}).items(): identity.policy = loads(content) - identity.tags = identity_attributes["Tags"] + identity.tags = identity_attributes.get("Tags", []) + dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {} + identity.dkim_status = dkim_attrs.get("Status") + identity.dkim_signing_enabled = dkim_attrs.get("SigningEnabled", False) + identity.dkim_signing_attributes_origin = dkim_attrs.get( + "SigningAttributesOrigin" + ) except Exception as error: logger.error( @@ -67,3 +73,6 @@ class Identity(BaseModel): type: Optional[str] policy: Optional[dict] = None tags: Optional[list] = [] + dkim_status: Optional[str] = None + dkim_signing_attributes_origin: Optional[str] = None + dkim_signing_enabled: Optional[bool] = False diff --git a/prowler/providers/googleworkspace/services/groups/__init__.py b/prowler/providers/googleworkspace/services/groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_client.py b/prowler/providers/googleworkspace/services/groups/groups_client.py new file mode 100644 index 0000000000..0f4881b52c --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, +) + +groups_client = Groups(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json new file mode 100644 index 0000000000..c2725de695 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_creation_restricted", + "CheckTitle": "Group creation is restricted to admins with no external members or incoming email", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **creation settings** control who can create groups and whether group owners can add external members or allow incoming email from outside the organization. Restricting creation to admins and disabling external member and incoming email options limits the attack surface.", + "Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/10308022", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Creating groups**\n4. Select **Only organization admins can create groups**\n5. Uncheck **Group owners can allow external members**\n6. Uncheck **Group owners can allow incoming email from outside the organization**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **group creation** to **organization admins** and disable **external member** and **incoming email** options to maintain control over group membership and communication boundaries.", + "Url": "https://hub.prowler.com/check/groups_creation_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_external_access_restricted", + "groups_view_conversations_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py new file mode 100644 index 0000000000..ad9ce534ed --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py @@ -0,0 +1,76 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_creation_restricted(Check): + """Check that group creation is restricted to admins only with no external members or incoming email. + + This check verifies three sub-settings: + - Only organization admins can create groups (not all users) + - Group owners cannot allow external members + - Group owners cannot allow incoming email from outside the organization + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + policies = groups_client.policies + domain = groups_client.provider.identity.domain + + access_level = policies.create_groups_access_level + external_members = policies.owners_can_allow_external_members + incoming_mail = policies.owners_can_allow_incoming_mail_from_public + + issues = [] + + # Check creation access level + # Default is USERS_IN_DOMAIN (insecure) — only ADMIN_ONLY is compliant + if access_level is None or access_level != "ADMIN_ONLY": + effective = access_level or "USERS_IN_DOMAIN (default)" + issues.append( + f"group creation is set to {effective} instead of ADMIN_ONLY" + ) + + # Check external members + # Default is false (secure) — only false is compliant + if external_members is True: + issues.append("group owners can allow external members") + + # Check incoming mail from outside + # Default is false (secure) — only true is non-compliant + if incoming_mail is True: + issues.append( + "group owners can allow incoming email from outside the organization" + ) + + if not issues: + report.status = "PASS" + report.status_extended = ( + f"Group creation is properly restricted in domain {domain}: " + f"admin-only creation, no external members, " + f"no incoming email from outside." + ) + else: + report.status = "FAIL" + issues_text = "; ".join(issues) + report.status_extended = ( + f"Group creation is not fully restricted " + f"in domain {domain}: {issues_text}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json new file mode 100644 index 0000000000..3656879182 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_external_access_restricted", + "CheckTitle": "Accessing groups from outside the organization is set to private", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **external access** controls whether people outside the organization can view and search for groups. When set to private, only users within the domain can discover and access groups, while external users can still email a group if the group's own settings allow it.", + "Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/10308022", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Accessing groups from outside this organization** to **Private**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set **external group access** to **private** to prevent external parties from viewing or searching for organizational groups.", + "Url": "https://hub.prowler.com/check/groups_external_access_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_creation_restricted", + "groups_view_conversations_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py new file mode 100644 index 0000000000..4ac057b905 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_external_access_restricted(Check): + """Check that accessing groups from outside the organization is set to private. + + This check verifies that the domain-level Groups for Business policy + restricts external access so that only domain users can view groups, + preventing information exposure to external parties. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + collaboration = groups_client.policies.collaboration_capability + domain = groups_client.provider.identity.domain + + if collaboration == "DOMAIN_USERS_ONLY": + report.status = "PASS" + report.status_extended = ( + f"Groups external access is set to private (domain users only) " + f"in domain {domain}." + ) + elif collaboration is None: + report.status = "PASS" + report.status_extended = ( + f"Groups external access uses Google's secure default " + f"configuration (private) in domain {domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Groups external access is set to {collaboration} " + f"in domain {domain}. " + f"External access should be set to private (domain users only)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/groups/groups_service.py b/prowler/providers/googleworkspace/services/groups/groups_service.py new file mode 100644 index 0000000000..1ebc0508e3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Groups(GoogleWorkspaceService): + """Google Workspace Groups for Business service for auditing domain-level group policies. + + Uses the Cloud Identity Policy API v1 to read group sharing, creation, + and conversation viewing settings configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = GroupsPolicies() + self.policies_fetched = False + self._fetch_groups_for_business_policies() + + def _fetch_groups_for_business_policies(self): + """Fetch Groups for Business policies from the Cloud Identity Policy API v1.""" + logger.info("Groups for Business - Fetching policies...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("groups_for_business.*")', + ) + fetch_succeeded = True + + while request is not None: + try: + response = request.execute() + + for policy in response.get("policies", []): + if not self._is_customer_level_policy(policy): + continue + + setting = policy.get("setting", {}) + setting_type = setting.get("type", "").removeprefix("settings/") + logger.debug(f"Processing setting type: {setting_type}") + + value = setting.get("value", {}) + + if setting_type == "groups_for_business.groups_sharing": + self.policies.collaboration_capability = value.get( + "collaborationCapability" + ) + self.policies.create_groups_access_level = value.get( + "createGroupsAccessLevel" + ) + self.policies.owners_can_allow_external_members = value.get( + "ownersCanAllowExternalMembers" + ) + self.policies.owners_can_allow_incoming_mail_from_public = ( + value.get("ownersCanAllowIncomingMailFromPublic") + ) + self.policies.view_topics_default_access_level = value.get( + "viewTopicsDefaultAccessLevel" + ) + self.policies.owners_can_hide_groups = value.get( + "ownersCanHideGroups" + ) + self.policies.new_groups_are_hidden = value.get( + "newGroupsAreHidden" + ) + logger.debug( + "Groups for Business sharing settings fetched." + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Groups for Business policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Groups for Business policies fetched - " + f"Collaboration: {self.policies.collaboration_capability}, " + f"Creation: {self.policies.create_groups_access_level}, " + f"View topics: {self.policies.view_topics_default_access_level}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Groups for Business policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class GroupsPolicies(BaseModel): + """Model for domain-level Groups for Business policy settings.""" + + # groups_for_business.groups_sharing + collaboration_capability: Optional[str] = None + create_groups_access_level: Optional[str] = None + owners_can_allow_external_members: Optional[bool] = None + owners_can_allow_incoming_mail_from_public: Optional[bool] = None + view_topics_default_access_level: Optional[str] = None + owners_can_hide_groups: Optional[bool] = None + new_groups_are_hidden: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json new file mode 100644 index 0000000000..b90fe6041b --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_view_conversations_restricted", + "CheckTitle": "Default permission to view conversations is set to all group members", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **conversation viewing** controls who can see group conversations by default. Restricting this to group members only ensures that conversations are visible only to participants, not all users in the organization or external parties.", + "Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/10308022", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Default for permission to view conversations** to **All group members**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default **permission to view conversations** to **all group members** to ensure group discussions are only visible to their participants.", + "Url": "https://hub.prowler.com/check/groups_view_conversations_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_external_access_restricted", + "groups_creation_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py new file mode 100644 index 0000000000..66fb15a43b --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_view_conversations_restricted(Check): + """Check that the default permission to view conversations is set to All Group Members. + + This check verifies that the domain-level Groups for Business policy + restricts conversation viewing to group members only, preventing + broader access by all organization users or anyone. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + view_access = groups_client.policies.view_topics_default_access_level + domain = groups_client.provider.identity.domain + + if view_access == "GROUP_MEMBERS": + report.status = "PASS" + report.status_extended = ( + f"Default permission to view conversations is set to " + f"all group members in domain {domain}." + ) + elif view_access is None: + report.status = "FAIL" + report.status_extended = ( + f"Default permission to view conversations uses Google's default " + f"configuration (all organization users) in domain {domain}. " + f"It should be restricted to all group members only." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Default permission to view conversations is set to " + f"{view_access} in domain {domain}. " + f"It should be restricted to all group members only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/okta/exceptions/exceptions.py b/prowler/providers/okta/exceptions/exceptions.py index 20a20c1b93..04dd71bef0 100644 --- a/prowler/providers/okta/exceptions/exceptions.py +++ b/prowler/providers/okta/exceptions/exceptions.py @@ -34,6 +34,10 @@ class OktaBaseException(ProwlerException): "message": "Okta service app is missing required scopes", "remediation": "Have a Super Admin grant the required *.read scopes to the service app and assign the Read-Only Administrator role.", }, + (14007, "OktaInvalidProviderIdError"): { + "message": "The provided provider_id does not match the credentials org domain", + "remediation": "Check the provider_id (Okta org domain) and ensure it matches the org the service app credentials were issued for.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -110,3 +114,10 @@ class OktaInsufficientPermissionsError(OktaCredentialsError): super().__init__( 14006, file=file, original_exception=original_exception, message=message ) + + +class OktaInvalidProviderIdError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14007, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 53095d5488..f863585bfd 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -22,6 +22,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, OktaSetUpSessionError, @@ -348,8 +349,17 @@ class OktaProvider(Provider): okta_private_key_file: str = "", okta_scopes: Optional[Union[str, list[str]]] = None, raise_on_exception: bool = True, + provider_id: str = None, ) -> Connection: - """Test the connection to Okta with the provided OAuth credentials.""" + """Test the connection to Okta with the provided OAuth credentials. + + Args: + provider_id: The provider ID (Okta org domain). When supplied, the + authenticated org domain must match it — guards against the + stored provider UID drifting from the org the credentials were + actually issued for. Compared case-insensitively, matching the + normalization applied during session setup. + """ try: OktaProvider.validate_arguments( okta_org_domain=okta_org_domain, @@ -364,7 +374,17 @@ class OktaProvider(Provider): private_key_file=okta_private_key_file, scopes=okta_scopes, ) - OktaProvider.setup_identity(session) + identity = OktaProvider.setup_identity(session) + + if provider_id and provider_id.strip().lower() != identity.org_domain: + raise OktaInvalidProviderIdError( + file=os.path.basename(__file__), + message=( + f"The provider ID '{provider_id}' does not match the " + f"authenticated Okta org domain '{identity.org_domain}'." + ), + ) + return Connection(is_connected=True) except Exception as error: logger.critical( diff --git a/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py b/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py new file mode 100644 index 0000000000..c192217de3 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py @@ -0,0 +1,156 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ModelRegistry +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +registry_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-registry/unknown" + + +class Test_sagemaker_models_registry_in_use: + def test_no_registries(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 0 + + def test_registry_no_groups(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=False, + has_approved_packages=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has no Model Package Groups." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_groups_no_approved_packages(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=True, + has_approved_packages=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has Model Package Groups but no approved model packages." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_with_approved_packages(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=True, + has_approved_packages=True, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has at least one approved model package." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index f4bc26c4e8..d49a506fd9 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -13,6 +13,11 @@ from tests.providers.aws.utils import ( set_mocked_aws_provider, ) +test_model_package_group_name = "test-model-package-group" +test_model_package_group_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package-group/{test_model_package_group_name}" +test_model_package_name = "test-model-package" +test_model_package_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package/{test_model_package_name}/1" + test_notebook_instance = "test-notebook-instance" notebook_instance_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}" test_model = "test-model" @@ -94,6 +99,25 @@ def mock_make_api_call(self, operation_name, kwarg): "EnableNetworkIsolation": True, "EnableInterContainerTrafficEncryption": True, } + if operation_name == "ListModelPackageGroups": + return { + "ModelPackageGroupSummaryList": [ + { + "ModelPackageGroupName": test_model_package_group_name, + "ModelPackageGroupArn": test_model_package_group_arn, + }, + ] + } + if operation_name == "ListModelPackages": + return { + "ModelPackageSummaryList": [ + { + "ModelPackageName": test_model_package_name, + "ModelPackageArn": test_model_package_arn, + "ModelApprovalStatus": "Approved", + }, + ] + } if operation_name == "ListTags": return { "Tags": [ @@ -379,3 +403,33 @@ class Test_SageMaker_Service: if c[0][0] == sagemaker_service._list_tags_for_resource ] assert len(tag_calls) == 5 + + # Test SageMaker list model package groups + def test_list_model_package_groups(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + assert len(sagemaker.sagemaker_model_registries) == 1 + registry = sagemaker.sagemaker_model_registries[0] + assert registry.region == AWS_REGION_EU_WEST_1 + assert registry.has_groups is True + assert registry.has_approved_packages is True + + def test_list_model_package_groups_access_denied(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + def mock_access_denied(self, operation_name, kwarg): + if operation_name == "ListModelPackageGroups": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform sagemaker:ListModelPackageGroups", + } + }, + "ListModelPackageGroups", + ) + return make_api_call(self, operation_name, kwarg) + + with patch("botocore.client.BaseClient._make_api_call", new=mock_access_denied): + sagemaker = SageMaker(aws_provider) + assert sagemaker.sagemaker_model_registries == [] diff --git a/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py new file mode 100644 index 0000000000..be4fda3cf4 --- /dev/null +++ b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py @@ -0,0 +1,328 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.ses.ses_service import SES +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call_dkim_pass(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pass", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_not_started(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-not-started", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "NOT_STARTED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_failed(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-failed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "FAILED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_pending(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pending", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "PENDING", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_success_not_enabled(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-verified-not-signed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +class Test_ses_identity_dkim_enabled: + @mock_aws + def test_no_identities(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 0 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pass, + ) + def test_identity_dkim_enabled_and_verified(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pass has DKIM signing enabled and verified." + ) + assert result[0].resource_id == "test-domain-dkim-pass" + assert ( + result[0].resource_arn + == f"arn:aws:ses:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:identity/test-domain-dkim-pass" + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_not_started, + ) + def test_identity_dkim_not_started(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-not-started has DKIM signing not verified (status: NOT_STARTED)." + ) + assert result[0].resource_id == "test-domain-dkim-not-started" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_failed, + ) + def test_identity_dkim_failed(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-failed has DKIM signing failed verification." + ) + assert result[0].resource_id == "test-domain-dkim-failed" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pending, + ) + def test_identity_dkim_pending(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pending has DKIM signing not verified (status: PENDING)." + ) + assert result[0].resource_id == "test-domain-dkim-pending" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_success_not_enabled, + ) + def test_identity_dkim_verified_but_not_enabled(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-verified-not-signed has DKIM verified but signing is disabled." + ) + assert result[0].resource_id == "test-domain-dkim-verified-not-signed" + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/ses/ses_service_test.py b/tests/providers/aws/services/ses/ses_service_test.py index dc1e6eb710..223ed94365 100644 --- a/tests/providers/aws/services/ses/ses_service_test.py +++ b/tests/providers/aws/services/ses/ses_service_test.py @@ -29,6 +29,11 @@ def mock_make_api_call(self, operation_name, kwarg): "policy1": '{"policy1": "value1"}', }, "Tags": {"tag1": "value1", "tag2": "value2"}, + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, } return make_api_call(self, operation_name, kwarg) @@ -78,3 +83,6 @@ class Test_SES_Service: assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1 assert ses.email_identities[arn].policy == {"policy1": "value1"} assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"} + assert ses.email_identities[arn].dkim_status == "SUCCESS" + assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES" + assert ses.email_identities[arn].dkim_signing_enabled is True diff --git a/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py new file mode 100644 index 0000000000..b91457f7b0 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py @@ -0,0 +1,271 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsCreationRestricted: + def test_pass_all_restricted(self): + """Test PASS when all creation settings are secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly restricted" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ).dict() + ) + + def test_fail_users_in_domain(self): + """Test FAIL when anyone in org can create groups""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="USERS_IN_DOMAIN", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "USERS_IN_DOMAIN" in findings[0].status_extended + + def test_fail_external_members_allowed(self): + """Test FAIL when external members are allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=True, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "external members" in findings[0].status_extended + + def test_fail_incoming_mail_allowed(self): + """Test FAIL when incoming email from outside is allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=True, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "incoming email" in findings[0].status_extended + + def test_fail_all_defaults_none(self): + """Test FAIL when all settings are None — only USERS_IN_DOMAIN default is insecure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies() + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + # Only creation access level has an insecure default (USERS_IN_DOMAIN) + assert "ADMIN_ONLY" in findings[0].status_extended + assert "incoming email" not in findings[0].status_extended + assert "external members" not in findings[0].status_extended + + def test_pass_admin_only_others_none(self): + """Test PASS when creation is ADMIN_ONLY and boolean settings are None (secure defaults)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly restricted" in findings[0].status_extended + + def test_fail_multiple_issues(self): + """Test FAIL with all three sub-settings non-compliant""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ANYONE_CAN_CREATE", + owners_can_allow_external_members=True, + owners_can_allow_incoming_mail_from_public=True, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_CREATE" in findings[0].status_extended + assert "external members" in findings[0].status_extended + assert "incoming email" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py new file mode 100644 index 0000000000..ec25683728 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py @@ -0,0 +1,132 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsExternalAccessRestricted: + def test_pass_domain_users_only(self): + """Test PASS when external access is set to DOMAIN_USERS_ONLY""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + collaboration_capability="DOMAIN_USERS_ONLY" + ) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "private" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies(collaboration_capability="DOMAIN_USERS_ONLY").dict() + ) + + def test_fail_anyone_can_access(self): + """Test FAIL when external access is set to ANYONE_CAN_ACCESS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + collaboration_capability="ANYONE_CAN_ACCESS" + ) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_ACCESS" in findings[0].status_extended + + def test_pass_no_policy_set(self): + """Test PASS when no explicit policy is set (None) - Google default is DOMAIN_USERS_ONLY (secure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies(collaboration_capability=None) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/groups/groups_service_test.py b/tests/providers/googleworkspace/services/groups/groups_service_test.py new file mode 100644 index 0000000000..86b031a8a2 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_service_test.py @@ -0,0 +1,287 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestGroupsService: + def test_fetch_policies_all_settings(self): + """Test fetching all Groups for Business policy settings from Cloud Identity API""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_credentials = MagicMock() + mock_session = MagicMock() + mock_session.credentials = mock_credentials + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "DOMAIN_USERS_ONLY", + "createGroupsAccessLevel": "ADMIN_ONLY", + "ownersCanAllowExternalMembers": False, + "ownersCanAllowIncomingMailFromPublic": False, + "viewTopicsDefaultAccessLevel": "GROUP_MEMBERS", + "ownersCanHideGroups": False, + "newGroupsAreHidden": False, + }, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert groups.policies.create_groups_access_level == "ADMIN_ONLY" + assert groups.policies.owners_can_allow_external_members is False + assert groups.policies.owners_can_allow_incoming_mail_from_public is False + assert groups.policies.view_topics_default_access_level == "GROUP_MEMBERS" + assert groups.policies.owners_can_hide_groups is False + assert groups.policies.new_groups_are_hidden is False + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability is None + assert groups.policies.create_groups_access_level is None + assert groups.policies.owners_can_allow_external_members is None + assert groups.policies.owners_can_allow_incoming_mail_from_public is None + assert groups.policies.view_topics_default_access_level is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_ignores_ou_and_group_level(self): + """Test that OU-level and group-level policies are skipped, only customer-level used""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "DOMAIN_USERS_ONLY", + "createGroupsAccessLevel": "ADMIN_ONLY", + }, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "ANYONE_CAN_ACCESS", + }, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "ANYONE_CAN_ACCESS", + }, + }, + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert groups.policies.create_groups_access_level == "ADMIN_ONLY" + + def test_policies_model(self): + """Test GroupsPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, + ) + + policies = GroupsPolicies( + collaboration_capability="DOMAIN_USERS_ONLY", + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + view_topics_default_access_level="GROUP_MEMBERS", + owners_can_hide_groups=False, + new_groups_are_hidden=False, + ) + + assert policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert policies.create_groups_access_level == "ADMIN_ONLY" + assert policies.owners_can_allow_external_members is False + assert policies.owners_can_allow_incoming_mail_from_public is False + assert policies.view_topics_default_access_level == "GROUP_MEMBERS" + assert policies.owners_can_hide_groups is False + assert policies.new_groups_are_hidden is False diff --git a/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py new file mode 100644 index 0000000000..761073cc89 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py @@ -0,0 +1,165 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsViewConversationsRestricted: + def test_pass_group_members(self): + """Test PASS when view conversations is set to GROUP_MEMBERS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="GROUP_MEMBERS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "group members" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies( + view_topics_default_access_level="GROUP_MEMBERS" + ).dict() + ) + + def test_fail_domain_users(self): + """Test FAIL when view conversations is set to DOMAIN_USERS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="DOMAIN_USERS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "DOMAIN_USERS" in findings[0].status_extended + + def test_fail_anyone_can_view(self): + """Test FAIL when view conversations is set to ANYONE_CAN_VIEW_TOPICS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="ANYONE_CAN_VIEW_TOPICS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_VIEW_TOPICS" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) - Google default is DOMAIN_USERS (insecure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies(view_topics_default_access_level=None) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "default" in findings[0].status_extended + assert "all organization users" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/okta/exceptions/okta_exceptions_test.py b/tests/providers/okta/exceptions/okta_exceptions_test.py index 20f06ba96e..28ea120d4e 100644 --- a/tests/providers/okta/exceptions/okta_exceptions_test.py +++ b/tests/providers/okta/exceptions/okta_exceptions_test.py @@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, OktaSetUpSessionError, @@ -20,6 +21,7 @@ EXPECTED_CODES = { OktaInvalidOrgDomainError: 14004, OktaPrivateKeyFileError: 14005, OktaInsufficientPermissionsError: 14006, + OktaInvalidProviderIdError: 14007, } diff --git a/tests/providers/okta/okta_provider_test.py b/tests/providers/okta/okta_provider_test.py index 8a9efbdcc8..da325d84ca 100644 --- a/tests/providers/okta/okta_provider_test.py +++ b/tests/providers/okta/okta_provider_test.py @@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, ) @@ -396,6 +397,55 @@ class Test_OktaProvider_test_connection: with pytest.raises(OktaEnvironmentVariableError): OktaProvider.test_connection() + def test_provider_id_match_succeeds(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN, + ) + assert connection.is_connected is True + assert connection.error is None + + def test_provider_id_match_is_case_insensitive(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN.upper(), + ) + assert connection.is_connected is True + + def test_provider_id_mismatch_raises(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + with pytest.raises(OktaInvalidProviderIdError): + OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + ) + + def test_provider_id_mismatch_returns_error_when_raise_disabled( + self, _clear_okta_env, tmp_path + ): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + raise_on_exception=False, + ) + assert connection.is_connected is False + assert isinstance(connection.error, OktaInvalidProviderIdError) + class Test_OktaProvider_print_credentials: def test_invokes_print_boxes_with_org_and_client(self, _clear_okta_env, tmp_path): diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 718a372a21..dc1b0c62a5 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.28.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213) + +--- + ## [1.27.0] (Prowler v5.27.0) ### 🚀 Added diff --git a/ui/actions/providers/providers.test.ts b/ui/actions/providers/providers.test.ts index ab3037db8b..4d56e45b4b 100644 --- a/ui/actions/providers/providers.test.ts +++ b/ui/actions/providers/providers.test.ts @@ -29,7 +29,7 @@ vi.mock("@/lib", () => ({ wait: vi.fn(), })); -vi.mock("@/lib/provider-credentials/build-crendentials", () => ({ +vi.mock("@/lib/provider-credentials/build-credentials", () => ({ buildSecretConfig: vi.fn(() => ({ secretType: "access-secret-key", secret: { key: "value" }, diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 63c01332b1..010646dc34 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib"; -import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials"; +import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { appendSanitizedProviderInFilters } from "@/lib/provider-filters"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index b7a4b3a5ae..62154f1042 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({ OracleCloudProviderBadge: () => Oracle Cloud, AlibabaCloudProviderBadge: () => Alibaba Cloud, VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, })); vi.mock("@/components/shadcn/select/multiselect", () => ({ diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index e76700013b..4e7ca5bf79 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -16,6 +16,7 @@ import { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -51,6 +52,7 @@ const PROVIDER_ICON: Record = { cloudflare: , openstack: , vercel: , + okta: , }; /** Common props shared by both batch and instant modes. */ diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index 8307821992..d7e027cae2 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({ CloudflareProviderBadge: () => Cloudflare, OpenStackProviderBadge: () => OpenStack, VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, })); vi.mock("@/components/shadcn/select/multiselect", () => ({ diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index f71245af30..9d0b0de3b5 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -89,6 +89,11 @@ const VercelProviderBadge = lazy(() => default: m.VercelProviderBadge, })), ); +const OktaProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OktaProviderBadge, + })), +); type IconProps = { width: number; height: number }; @@ -160,6 +165,10 @@ const PROVIDER_DATA: Record< label: "Vercel", icon: VercelProviderBadge, }, + okta: { + label: "Okta", + icon: OktaProviderBadge, + }, }; /** Common props shared by both batch and instant modes. */ diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts index 34c7b15450..fca0e4ae67 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -33,6 +33,7 @@ import { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -175,6 +176,11 @@ const KNOWN_NODE_VISUALS = { description: "Vercel Account", Icon: VercelProviderBadge, }, + oktaaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Okta Account", + Icon: OktaProviderBadge, + }, s3bucket: { category: NODE_CATEGORY.STORAGE, description: "S3 Bucket", diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 3d061a0b32..05a35b3dc2 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -11,6 +11,7 @@ import { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -34,6 +35,7 @@ export const PROVIDER_ICONS = { cloudflare: CloudflareProviderBadge, openstack: OpenStackProviderBadge, vercel: VercelProviderBadge, + okta: OktaProviderBadge, } as const; interface ProviderIconCellProps { diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts index 729eaf4779..7c107ee76a 100644 --- a/ui/components/icons/providers-badge/index.ts +++ b/ui/components/icons/providers-badge/index.ts @@ -14,6 +14,7 @@ import { ImageProviderBadge } from "./image-provider-badge"; import { KS8ProviderBadge } from "./ks8-provider-badge"; import { M365ProviderBadge } from "./m365-provider-badge"; import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge"; +import { OktaProviderBadge } from "./okta-provider-badge"; import { OpenStackProviderBadge } from "./openstack-provider-badge"; import { OracleCloudProviderBadge } from "./oraclecloud-provider-badge"; import { VercelProviderBadge } from "./vercel-provider-badge"; @@ -31,6 +32,7 @@ export { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -53,4 +55,5 @@ export const PROVIDER_BADGE_BY_NAME: Record> = { Cloudflare: CloudflareProviderBadge, OpenStack: OpenStackProviderBadge, Vercel: VercelProviderBadge, + Okta: OktaProviderBadge, }; diff --git a/ui/components/icons/providers-badge/okta-provider-badge.tsx b/ui/components/icons/providers-badge/okta-provider-badge.tsx new file mode 100644 index 0000000000..0c49b89398 --- /dev/null +++ b/ui/components/icons/providers-badge/okta-provider-badge.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +import { IconSvgProps } from "@/types"; + +const WHEEL_PATH = + "M34.6,0.4l-1.3,16c-0.6-0.1-1.2-0.1-1.9-0.1c-0.8,0-1.6,0.1-2.3,0.2l-0.7-7.7c0-0.2,0.2-0.5,0.4-0.5h1.3l-0.6-7.8c0-0.2,0.2-0.5,0.4-0.5h4.3C34.5,0,34.7,0.2,34.6,0.4L34.6,0.4L34.6,0.4z M23.8,1.2c-0.1-0.2-0.3-0.4-0.5-0.3l-4,1.5C19,2.5,18.9,2.8,19,3l3.3,7.1l-1.2,0.5c-0.2,0.1-0.3,0.3-0.2,0.6l3.3,7c1.2-0.7,2.5-1.2,3.9-1.5L23.8,1.2L23.8,1.2z M14,5.7l9.3,13.1c-1.2,0.8-2.2,1.7-3.1,2.7L14.5,16c-0.2-0.2-0.2-0.5,0-0.6l1-0.8L10,9c-0.2-0.2-0.2-0.5,0-0.6l3.3-2.7C13.5,5.4,13.8,5.5,14,5.7L14,5.7z M6.2,13.2c-0.2-0.1-0.5-0.1-0.6,0.1l-2.1,3.7c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.4l-0.7,1.1c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.2c0.5-1.3,1.2-2.5,2-3.6L6.2,13.2z M0.9,23.3c0-0.2,0.3-0.4,0.5-0.3l15.5,4c-0.4,1.3-0.6,2.7-0.7,4.1l-7.8-0.6c-0.2,0-0.4-0.2-0.4-0.5l0.2-1.3L0.6,28c-0.2,0-0.4-0.2-0.4-0.5L0.9,23.3L0.9,23.3L0.9,23.3z M0.4,33.8C0.1,33.8,0,34,0,34.3l0.8,4.2c0,0.2,0.3,0.4,0.5,0.3l7.6-2l0.2,1.3c0,0.2,0.3,0.4,0.5,0.3l7.5-2.1c-0.4-1.3-0.7-2.7-0.8-4.1L0.4,33.8L0.4,33.8z M2.9,44.9c-0.1-0.2,0-0.5,0.2-0.6l14.5-6.9c0.5,1.3,1.3,2.5,2.2,3.6l-6.3,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L12,44.3l-6.5,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L2.9,44.9L2.9,44.9z M20.4,41.9L9.1,53.3c-0.2,0.2-0.2,0.5,0,0.6l3.3,2.7c0.2,0.2,0.5,0.1,0.6-0.1l4.6-6.4l1,0.9c0.2,0.2,0.5,0.1,0.6-0.1l4.4-6.4C22.4,43.8,21.3,42.9,20.4,41.9L20.4,41.9z M18.2,60.1c-0.2-0.1-0.3-0.3-0.2-0.6L24.6,45c1.2,0.6,2.6,1.1,3.9,1.4l-2,7.5c-0.1,0.2-0.3,0.4-0.5,0.3l-1.2-0.5l-2.1,7.6c-0.1,0.2-0.3,0.4-0.5,0.3L18.2,60.1L18.2,60.1L18.2,60.1z M29.6,46.6l-1.3,16c0,0.2,0.2,0.5,0.4,0.5H33c0.2,0,0.4-0.2,0.4-0.5l-0.6-7.8h1.3c0.2,0,0.4-0.2,0.4-0.5l-0.7-7.7c-0.8,0.1-1.5,0.2-2.3,0.2C30.9,46.7,30.2,46.7,29.6,46.6L29.6,46.6z M45.1,3.4c0.1-0.2,0-0.5-0.2-0.6l-4-1.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2.1,7.6l-1.2-0.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2,7.5c1.4,0.3,2.7,0.8,3.9,1.4L45.1,3.4L45.1,3.4z M53.9,9.7L42.6,21.1c-0.9-1-2-1.9-3.2-2.6l4.4-6.4c0.1-0.2,0.4-0.2,0.6-0.1l1,0.9l4.6-6.4c0.1-0.2,0.4-0.2,0.6-0.1l3.3,2.7C54,9.3,54,9.6,53.9,9.7L53.9,9.7z M59.9,18.7c0.2-0.1,0.3-0.4,0.2-0.6L58,14.4c-0.1-0.2-0.4-0.3-0.6-0.1l-6.5,4.5l-0.7-1.1c-0.1-0.2-0.4-0.3-0.6-0.1L43.3,22c0.9,1.1,1.6,2.3,2.2,3.6L59.9,18.7L59.9,18.7z M62.2,24.5l0.7,4.2c0,0.2-0.1,0.5-0.4,0.5l-15.9,1.5c-0.1-1.4-0.4-2.8-0.8-4.1l7.5-2.1c0.2-0.1,0.5,0.1,0.5,0.3l0.2,1.3l7.6-2C61.9,24.1,62.1,24.3,62.2,24.5L62.2,24.5L62.2,24.5z M61.5,40c0.2,0.1,0.5-0.1,0.5-0.3l0.7-4.2c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.7l0.2-1.3c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.6c0,1.4-0.3,2.8-0.7,4.1L61.5,40L61.5,40L61.5,40z M57.4,49.6c-0.1,0.2-0.4,0.3-0.6,0.1l-13.2-9.1c0.8-1.1,1.5-2.3,2-3.6l7.1,3.2c0.2,0.1,0.3,0.4,0.2,0.6L52.2,42l7.1,3.4c0.2,0.1,0.3,0.4,0.2,0.6L57.4,49.6C57.4,49.6,57.4,49.6,57.4,49.6z M39.7,44.2L49,57.3c0.1,0.2,0.4,0.2,0.6,0.1l3.3-2.7c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.6l1-0.8c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.5C42,42.6,40.9,43.5,39.7,44.2L39.7,44.2L39.7,44.2z M39.7,62c-0.2,0.1-0.5-0.1-0.5-0.3l-4.2-15.4c1.4-0.3,2.7-0.8,3.9-1.5l3.3,7c0.1,0.2,0,0.5-0.2,0.6l-1.2,0.5l3.3,7.1c0.1,0.2,0,0.5-0.2,0.6L39.7,62L39.7,62L39.7,62z"; + +export const OktaProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const w = size || width; + const h = size || height; + + if (!mounted) { + return ( +