diff --git a/.env b/.env index 99ec6b55d2..03bc3dc7a4 100644 --- a/.env +++ b/.env @@ -174,7 +174,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.46.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index 38cbfb6253..19cc3cd217 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -38,13 +38,16 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - # PRs only need the diff range; push to master/release walks the new range from event.before. - # 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone. - fetch-depth: 50 + # Full history: a shallow clone cuts the PR's ancestry, git can no longer exclude commits + # already on the base branch, and TruffleHog scans the cut-off commit as a root (the whole + # tree as added). PRs that merge master in hit this and fail on pre-existing content. + fetch-depth: 0 persist-credentials: false - name: Scan diff for secrets with TruffleHog # Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags. - uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3 + uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2 with: + # The action runs the `latest` image unless told otherwise: pin the scanner to the action's release. + version: 3.99.2@sha256:47a84bc18a0d04a165498bbbd3bacfd84176661cbc91f8e0f85467f6a771e99a extra_args: --results=verified,unknown diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 65809f15f0..238c126802 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -117,7 +117,8 @@ jobs: fi echo "Branch $BRANCH_NAME does not exist, creating it from master ($MASTER_SHA)..." gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${BRANCH_NAME}" -f sha="${MASTER_SHA}" - git checkout -b "$BRANCH_NAME" "$MASTER_SHA" + git update-ref "refs/remotes/origin/${BRANCH_NAME}" "$MASTER_SHA" + git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" else echo "ERROR: Branch $BRANCH_NAME does not exist. For patch releases (X.Y.Z), the branch should already exist." exit 1 diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 207a1f9b16..2492fe3d49 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -640,6 +640,30 @@ jobs: flags: prowler-py${{ matrix.python-version }}-e2enetworks files: ./e2enetworks_coverage.xml + # Huawei Cloud Provider + - name: Check if Huawei Cloud files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-huaweicloud + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/huaweicloud/** + ./tests/**/huaweicloud/** + ./uv.lock + + - name: Run Huawei Cloud tests + if: steps.changed-huaweicloud.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/huaweicloud --cov-report=xml:huaweicloud_coverage.xml tests/providers/huaweicloud + + - name: Upload Huawei Cloud coverage to Codecov + if: steps.changed-huaweicloud.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-huaweicloud + files: ./huaweicloud_coverage.xml + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.grype.yaml b/.grype.yaml index 81e6be2eb9..18345342e8 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -7,66 +7,19 @@ ignore: - # Modules compiled into the Trivy binary we ship. - # Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile. - # CVE-2026-71556 is the same temporary exception documented in .trivyignore.yaml: - # Trivy 0.73.0 still embeds go-git 5.19.1, while the 5.19.2 fix is merged only on - # Trivy main. Remove this entry with the Trivy exception by 2026-09-15. - # https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46 - # https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b - - vulnerability: CVE-2026-71556 - package: - name: github.com/go-git/go-git/v5 - # CVE-2026-84304 is the same temporary exception documented in .trivyignore.yaml: - # Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.83.1 fix is not in any release. - # Prowler only runs `trivy image` / `trivy fs`, never client/server mode, so no gRPC - # endpoint exists in the image. Pinned to the embedded version so the rule stops - # matching on its own once Trivy bumps grpc. Remove with the Trivy exception by 2026-10-15. - # https://github.com/aquasecurity/trivy/pull/11176 - - vulnerability: CVE-2026-84304 - package: - name: google.golang.org/grpc - version: v1.82.1 - # CVE-2026-84445 is the same temporary exception documented in .trivyignore.yaml: - # Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.82.2 / 1.83.2 fix is not in any - # release. The panic needs a gRPC server built with `xds.NewGRPCServer()`; Prowler only - # runs `trivy image` / `trivy fs`, so the image serves no gRPC at all. Pinned to the - # embedded version so the rule stops matching on its own once Trivy bumps grpc. Remove - # with the Trivy exception by 2026-10-15. - # https://github.com/advisories/GHSA-2v4p-qf9q-27wj - - vulnerability: CVE-2026-84445 - package: - name: google.golang.org/grpc - version: v1.82.1 - # CVE-2026-56855 / CVE-2026-78662 are the same temporary exception documented in - # .trivyignore.yaml: Trivy 0.74.0 still embeds golang.org/x/crypto v0.55.0, while the - # 0.56.0 fix (published 2026-09-02) hasn't reached any Trivy release, or even Trivy - # main, yet. Pinned to the embedded version so the rule stops matching on its own once - # Trivy bumps it. Remove with the Trivy exception by 2026-10-15. - - vulnerability: CVE-2026-56855 - package: - name: golang.org/x/crypto - version: v0.55.0 - - vulnerability: CVE-2026-78662 - package: - name: golang.org/x/crypto - version: v0.55.0 - - vulnerability: CVE-2026-56852 - package: - name: golang.org/x/text - - vulnerability: GHSA-hrxh-6v49-42gf - package: - name: google.golang.org/grpc - - vulnerability: CVE-2026-50151 - package: - name: oras.land/oras-go/v2 - # Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module. # Not a dependency we declare, and not one we can upgrade independently. - vulnerability: CVE-2026-26127 package: name: Microsoft.Bcl.Memory + # Documented in .trivyignore.yaml: ships inside ExchangeOnlineManagement + # 3.10.1, the latest gallery release. Re-check on the next EXO release. + - vulnerability: CVE-2026-84008 + package: + name: System.Security.Cryptography.Pkcs + version: 10.0.0 + # The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins. # CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml: # fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 3683a23e0a..c13bd5f114 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -108,6 +108,23 @@ vulnerabilities: purls: - "pkg:nuget/System.Security.Cryptography.Xml" expired_at: 2027-01-31 + # Same manifest, pinned to 8.0.4: PowerShell's own copy is also flagged by these CVEs. + - id: CVE-2026-96222 + purls: + - "pkg:nuget/System.Security.Cryptography.Xml@8.0.4" + expired_at: 2027-01-31 + - id: CVE-2026-96958 + purls: + - "pkg:nuget/System.Security.Cryptography.Xml@8.0.4" + expired_at: 2027-01-31 + - id: CVE-2026-96959 + purls: + - "pkg:nuget/System.Security.Cryptography.Xml@8.0.4" + expired_at: 2027-01-31 + - id: CVE-2026-96960 + purls: + - "pkg:nuget/System.Security.Cryptography.Xml@8.0.4" + expired_at: 2027-01-31 - id: CVE-2026-13676 purls: - "pkg:npm/fast-uri" @@ -181,41 +198,12 @@ vulnerabilities: - "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1" expired_at: 2027-01-31 - # CVE-2026-78667 and CVE-2026-97031 are DoS bugs in the Go standard library: net/http - # burns CPU parsing a Range header with a large number of small ranges in FileServer, - # ServeContent and ServeFile, and crypto/tls lets a client exhaust server memory with - # multiple ECH outer extension references. Both are server-side code paths. Fixed in - # Go 1.26.9 and 1.27.2 (released 2026-10-08). Trivy 0.75.0, the latest published - # release and the version the images ship, is built with go1.27.1, and Trivy main - # still declares `go 1.27.0`: - # https://github.com/aquasecurity/trivy/blob/v0.75.0/go.mod - # Prowler invokes Trivy exclusively as `trivy fs` and `trivy image` on a local path, - # never `trivy server`, so no HTTP or TLS listener ever exists in the image; its only - # TLS use is as a client pulling the vulnerability database. The purl is pinned to the - # stdlib version so the entry retires itself on the next Trivy bump. Remove this - # temporary suppression as soon as a Trivy release is built with Go >= 1.27.2. - - id: CVE-2026-78667 + # Unlike the manifest entries above, this DLL ships in the image: + # ExchangeOnlineManagement/3.10.1/netCore/System.Security.Cryptography.Pkcs.dll. It is a + # DoS in CMS/PKCS parsing; Prowler only feeds it the tenant's own certificate. 3.10.1 is + # the latest gallery release, so only Microsoft can update it. Re-check on the next EXO + # release. + - id: CVE-2026-84008 purls: - - "pkg:golang/stdlib@v1.27.1" - expired_at: 2026-11-15 - - id: CVE-2026-97031 - purls: - - "pkg:golang/stdlib@v1.27.1" - expired_at: 2026-11-15 - - - id: CVE-2026-56852 - purls: - - "pkg:golang/golang.org/x/text" - expired_at: 2026-12-31 - - id: GHSA-hrxh-6v49-42gf - purls: - - "pkg:golang/google.golang.org/grpc" - expired_at: 2026-12-31 - - id: CVE-2026-50151 - purls: - - "pkg:golang/oras.land/oras-go/v2" - expired_at: 2026-12-31 - - id: CVE-2026-50163 - purls: - - "pkg:golang/oras.land/oras-go/v2" - expired_at: 2026-12-31 + - "pkg:nuget/System.Security.Cryptography.Pkcs@10.0.0" + expired_at: 2026-12-15 diff --git a/Dockerfile b/Dockerfile index 1a1ad42c7e..a9b5237fad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,22 +3,22 @@ FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4 LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" -ARG POWERSHELL_VERSION=7.5.11 +ARG POWERSHELL_VERSION=7.5.12 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 -ARG TRIVY_VERSION=0.75.0 +ARG TRIVY_VERSION=0.76.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f -ARG TRIVY_SHA256_ARM64=a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 -ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 -ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 +ARG TRIVY_SHA256_AMD64=e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7 +ARG TRIVY_SHA256_ARM64=aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c +ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd +ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index afd6c2d86d..4a0d6037ad 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.46.0] (Prowler v5.45.0) + +### 🐞 Fixed + +- OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865) +- Gunicorn workers are now recycled after 10000 requests (plus up to 1000 of jitter) to cap memory growth, configurable with `GUNICORN_MAX_REQUESTS` and `GUNICORN_MAX_REQUESTS_JITTER` [(#13047)](https://github.com/prowler-cloud/prowler/pull/13047) + +### 🔐 Security + +- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911) +- Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials [(#12912)](https://github.com/prowler-cloud/prowler/pull/12912) +- Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test [(#12952)](https://github.com/prowler-cloud/prowler/pull/12952) +- `django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953) +- `dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953) +- `urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953) +- `pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 [(#12955)](https://github.com/prowler-cloud/prowler/pull/12955) +- `GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token [(#12965)](https://github.com/prowler-cloud/prowler/pull/12965) +- Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009) +- PowerShell from 7.5.11 to 7.5.12 in the API container image, bundling .NET runtime 9.0.21 [(#13037)](https://github.com/prowler-cloud/prowler/pull/13037) + +--- + ## [1.45.0] (Prowler v5.44.0) ### 🚀 Added diff --git a/api/Dockerfile b/api/Dockerfile index d19c3c9474..ab954829f3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,22 +2,22 @@ FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4 LABEL maintainer="https://github.com/prowler-cloud/api" -ARG POWERSHELL_VERSION=7.5.11 +ARG POWERSHELL_VERSION=7.5.12 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 -ARG TRIVY_VERSION=0.75.0 +ARG TRIVY_VERSION=0.76.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f -ARG TRIVY_SHA256_ARM64=a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 -ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 -ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 +ARG TRIVY_SHA256_AMD64=e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7 +ARG TRIVY_SHA256_ARM64=aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c +ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd +ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d diff --git a/api/changelog.d/api-image-trivy-0-75-0.security.md b/api/changelog.d/api-image-trivy-0-75-0.security.md deleted file mode 100644 index 4d5759d29c..0000000000 --- a/api/changelog.d/api-image-trivy-0-75-0.security.md +++ /dev/null @@ -1 +0,0 @@ -Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy diff --git a/api/changelog.d/api-image-trivy-0-76-0.security.md b/api/changelog.d/api-image-trivy-0-76-0.security.md new file mode 100644 index 0000000000..59473ad497 --- /dev/null +++ b/api/changelog.d/api-image-trivy-0-76-0.security.md @@ -0,0 +1 @@ +Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 diff --git a/api/changelog.d/django-5-2-17.security.md b/api/changelog.d/django-5-2-17.security.md deleted file mode 100644 index ec1e6a7450..0000000000 --- a/api/changelog.d/django-5-2-17.security.md +++ /dev/null @@ -1 +0,0 @@ -`django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 diff --git a/api/changelog.d/dulwich-1-2-9.security.md b/api/changelog.d/dulwich-1-2-9.security.md deleted file mode 100644 index 8f1dd24253..0000000000 --- a/api/changelog.d/dulwich-1-2-9.security.md +++ /dev/null @@ -1 +0,0 @@ -`dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx diff --git a/api/changelog.d/kubeconfig-inline-credentials.security.md b/api/changelog.d/kubeconfig-inline-credentials.security.md deleted file mode 100644 index 8d6115d4c3..0000000000 --- a/api/changelog.d/kubeconfig-inline-credentials.security.md +++ /dev/null @@ -1 +0,0 @@ -Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test diff --git a/api/changelog.d/oci-home-region-bootstrap.fixed.md b/api/changelog.d/oci-home-region-bootstrap.fixed.md deleted file mode 100644 index 0d28492c2b..0000000000 --- a/api/changelog.d/oci-home-region-bootstrap.fixed.md +++ /dev/null @@ -1 +0,0 @@ -OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 diff --git a/api/changelog.d/pdf-report-account-id-overflow.fixed.md b/api/changelog.d/pdf-report-account-id-overflow.fixed.md new file mode 100644 index 0000000000..d97d9bc41d --- /dev/null +++ b/api/changelog.d/pdf-report-account-id-overflow.fixed.md @@ -0,0 +1 @@ +Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell diff --git a/api/changelog.d/pyjwt-2-14-0.security.md b/api/changelog.d/pyjwt-2-14-0.security.md deleted file mode 100644 index fab78f2937..0000000000 --- a/api/changelog.d/pyjwt-2-14-0.security.md +++ /dev/null @@ -1 +0,0 @@ -`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/api/changelog.d/pymongo-4-18-2.security.md b/api/changelog.d/pymongo-4-18-2.security.md deleted file mode 100644 index b621313d25..0000000000 --- a/api/changelog.d/pymongo-4-18-2.security.md +++ /dev/null @@ -1 +0,0 @@ -`pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 diff --git a/api/changelog.d/sentry-no-personal-data.security.md b/api/changelog.d/sentry-no-personal-data.security.md deleted file mode 100644 index b0c7d6d5fe..0000000000 --- a/api/changelog.d/sentry-no-personal-data.security.md +++ /dev/null @@ -1 +0,0 @@ -Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials diff --git a/api/changelog.d/urllib3-2-8-0.security.md b/api/changelog.d/urllib3-2-8-0.security.md deleted file mode 100644 index 4645bb5942..0000000000 --- a/api/changelog.d/urllib3-2-8-0.security.md +++ /dev/null @@ -1 +0,0 @@ -`urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 diff --git a/api/changelog.d/user-detail-cross-tenant-relations.security.md b/api/changelog.d/user-detail-cross-tenant-relations.security.md deleted file mode 100644 index 2ada35d1ea..0000000000 --- a/api/changelog.d/user-detail-cross-tenant-relations.security.md +++ /dev/null @@ -1 +0,0 @@ -`GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token diff --git a/api/pyproject.toml b/api/pyproject.toml index e20a4f3993..267bfd3d60 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.46.0" +version = "1.47.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 220afcb7d8..8c2bb7041d 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.46.0 + version: 1.47.0 description: |- Prowler API specification. diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 664fcd1c4a..240514420e 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -130,6 +130,8 @@ REST_FRAMEWORK = { SPECTACULAR_SETTINGS = { "SERVE_INCLUDE_SCHEMA": False, "COMPONENT_SPLIT_REQUEST": True, + # redoc@latest is 3.x, whose standalone bundle fails as a classic script + "REDOC_DIST": "https://cdn.jsdelivr.net/npm/redoc@2.5.1", "PREPROCESSING_HOOKS": [ "drf_spectacular_jsonapi.hooks.fix_nested_path_parameters", ], diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 8b17ad669d..5880d9f45c 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -53,6 +53,10 @@ preload_app = not DEBUG # that may take longer, such as complex API operations. timeout = env.int("GUNICORN_TIMEOUT", default=120) +# Recycle workers after N requests (+ jitter) to cap memory growth. GUNICORN_MAX_REQUESTS=0 disables. +max_requests = env.int("GUNICORN_MAX_REQUESTS", default=10000) +max_requests_jitter = env.int("GUNICORN_MAX_REQUESTS_JITTER", default=1000) + # Logging logconfig_dict = DJANGO_LOGGERS gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN) diff --git a/api/src/backend/tasks/jobs/reports/cis.py b/api/src/backend/tasks/jobs/reports/cis.py index 8a1cfb6eba..420c46252e 100644 --- a/api/src/backend/tasks/jobs/reports/cis.py +++ b/api/src/backend/tasks/jobs/reports/cis.py @@ -172,14 +172,10 @@ class CISReportGenerator(BaseComplianceReportGenerator): # Metadata table via base class helper info_rows = self._build_info_rows(data, language=self.config.language) - metadata_data = [] - for label, value in info_rows: - if label in ("Name:", "Description:") and value: - metadata_data.append( - [label, Paragraph(str(value), self.styles["normal_center"])] - ) - else: - metadata_data.append([label, value]) + metadata_data = [ + [label, Paragraph(escape_html(str(value)), self.styles["normal_center"])] + for label, value in info_rows + ] metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) metadata_table.setStyle( diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py index 44c874bfc1..a940e95416 100644 --- a/api/src/backend/tasks/jobs/reports/ens.py +++ b/api/src/backend/tasks/jobs/reports/ens.py @@ -13,7 +13,7 @@ from .base import ( get_requirement_metadata, ) from .charts import create_horizontal_bar_chart, create_radar_chart -from .components import get_color_for_compliance +from .components import escape_html, get_color_for_compliance from .config import ( COLOR_BG_BLUE, COLOR_BLUE, @@ -99,15 +99,10 @@ class ENSReportGenerator(BaseComplianceReportGenerator): # Compliance info table - use base class helper for consistency info_rows = self._build_info_rows(data, language="es") - # Convert tuples to lists and wrap long text in Paragraphs - info_data = [] - for label, value in info_rows: - if label in ("Nombre:", "Descripción:") and value: - info_data.append( - [label, Paragraph(value, self.styles["normal_center"])] - ) - else: - info_data.append([label, value]) + info_data = [ + [label, Paragraph(escape_html(str(value)), self.styles["normal_center"])] + for label, value in info_rows + ] info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) info_table.setStyle( diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py index ed936f9571..1775871f42 100644 --- a/api/src/backend/tasks/jobs/reports/nis2.py +++ b/api/src/backend/tasks/jobs/reports/nis2.py @@ -11,6 +11,7 @@ from .base import ( get_requirement_metadata, ) from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage +from .components import escape_html from .config import ( COLOR_BORDER_GRAY, COLOR_DARK_GRAY, @@ -111,15 +112,10 @@ class NIS2ReportGenerator(BaseComplianceReportGenerator): # Compliance metadata table - use base class helper for consistency info_rows = self._build_info_rows(data, language="en") - # Convert tuples to lists and wrap long text in Paragraphs - metadata_data = [] - for label, value in info_rows: - if label in ("Name:", "Description:") and value: - metadata_data.append( - [label, Paragraph(value, self.styles["normal_center"])] - ) - else: - metadata_data.append([label, value]) + metadata_data = [ + [label, Paragraph(escape_html(str(value)), self.styles["normal_center"])] + for label, value in info_rows + ] metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) metadata_table.setStyle( diff --git a/api/src/backend/tasks/tests/test_reports_cis.py b/api/src/backend/tasks/tests/test_reports_cis.py index b57780b8a4..530e58133d 100644 --- a/api/src/backend/tasks/tests/test_reports_cis.py +++ b/api/src/backend/tasks/tests/test_reports_cis.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest from api.models import StatusChoices +from reportlab.lib.units import inch from reportlab.platypus import Image, LongTable, Paragraph, Table from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData from tasks.jobs.reports import cis as cis_report_module @@ -582,3 +583,24 @@ class TestCISCoverPage: paragraphs = [e for e in elements if isinstance(e, Paragraph)] content = " ".join(str(p.text) for p in paragraphs) assert "AWS" in content + + def test_cover_page_wraps_long_account_id( + self, cis_generator, basic_cis_compliance_data + ): + provider = Mock() + provider.provider = "oraclecloud" + provider.uid = "ocid1.tenancy.oc1.." + "a" * 70 + provider.alias = None + basic_cis_compliance_data.provider_obj = provider + elements = cis_generator.create_cover_page(basic_cis_compliance_data) + table = next( + e + for e in elements + if isinstance(e, Table) + and any(row[0] == "Account ID:" for row in e._cellvalues) + ) + values = [row[1] for row in table._cellvalues] + assert all(isinstance(v, Paragraph) for v in values) + account_id = next(v for v in values if v.text == provider.uid) + _, height = account_id.wrap(4 * inch, 1000) + assert height > account_id.style.leading diff --git a/api/src/backend/tasks/tests/test_reports_ens.py b/api/src/backend/tasks/tests/test_reports_ens.py index 220c3e1427..ecb938bd38 100644 --- a/api/src/backend/tasks/tests/test_reports_ens.py +++ b/api/src/backend/tasks/tests/test_reports_ens.py @@ -2,6 +2,7 @@ import io from unittest.mock import Mock, patch import pytest +from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Table from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData from tasks.jobs.reports import ens as ens_report_module @@ -189,6 +190,31 @@ class TestENSCoverPage: tables = [e for e in elements if isinstance(e, Table)] assert len(tables) >= 1 # At least info table + def test_cover_page_wraps_long_account_id( + self, ens_generator, basic_ens_compliance_data + ): + provider = Mock() + provider.provider = "oraclecloud" + provider.uid = "ocid1.tenancy.oc1.." + "a" * 70 + provider.alias = None + basic_ens_compliance_data.provider_obj = provider + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + table = next( + e + for e in elements + if isinstance(e, Table) + and any(row[0] == "Account ID:" for row in e._cellvalues) + ) + values = [row[1] for row in table._cellvalues] + assert all(isinstance(v, Paragraph) for v in values) + account_id = next(v for v in values if v.text == provider.uid) + _, height = account_id.wrap(4 * inch, 1000) + assert height > account_id.style.leading + def test_cover_page_has_warning_about_manual( self, ens_generator, basic_ens_compliance_data ): diff --git a/api/src/backend/tasks/tests/test_reports_nis2.py b/api/src/backend/tasks/tests/test_reports_nis2.py index ea6e06477d..ce630648f8 100644 --- a/api/src/backend/tasks/tests/test_reports_nis2.py +++ b/api/src/backend/tasks/tests/test_reports_nis2.py @@ -2,6 +2,7 @@ import io from unittest.mock import Mock, patch import pytest +from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Table from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData from tasks.jobs.reports import nis2 as nis2_report_module @@ -214,6 +215,31 @@ class TestNIS2CoverPage: tables = [e for e in elements if isinstance(e, Table)] assert len(tables) >= 1 + def test_cover_page_wraps_long_account_id( + self, nis2_generator, basic_nis2_compliance_data + ): + provider = Mock() + provider.provider = "oraclecloud" + provider.uid = "ocid1.tenancy.oc1.." + "a" * 70 + provider.alias = None + basic_nis2_compliance_data.provider_obj = provider + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + table = next( + e + for e in elements + if isinstance(e, Table) + and any(row[0] == "Account ID:" for row in e._cellvalues) + ) + values = [row[1] for row in table._cellvalues] + assert all(isinstance(v, Paragraph) for v in values) + account_id = next(v for v in values if v.text == provider.uid) + _, height = account_id.wrap(4 * inch, 1000) + assert height > account_id.style.leading + # ============================================================================= # Executive Summary Tests diff --git a/api/uv.lock b/api/uv.lock index 58a1fffa90..afb8b9b2ca 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4203,7 +4203,7 @@ wheels = [ [package.optional-dependencies] broker = [ - { name = "pymsalruntime" }, + { name = "pymsalruntime", marker = "sys_platform == 'win32'" }, ] [[package]] @@ -4939,7 +4939,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.46.0" +version = "1.47.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 78a2c12805..cd20063106 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,68 @@ description: "Track new features, improvements, provider updates, and bug fixes rss: true --- + + ### 🤖 Azure — AI Services Coverage + + A new Azure AI Services service audits the Azure AI and Azure OpenAI accounts of each subscription, with nine checks across network exposure, authentication, encryption, identity, logging and content safety, all contributed by @rajeshnadiminti. Thank you! + + - `aiservices_account_public_network_access_disabled`, `aiservices_account_network_acls_default_action_deny`, `aiservices_account_uses_private_endpoint` and `aiservices_account_outbound_network_restricted` cover how the account can be reached and what it can reach. + - `aiservices_account_local_auth_disabled` and `aiservices_account_managed_identity_enabled` cover key-based access and the account's own identity. + - `aiservices_account_encrypted_with_cmk` and `aiservices_account_diagnostic_logging_enabled` cover customer-managed key encryption and diagnostic logs. + - `aiservices_deployment_content_filter_prompt_shield_enabled` verifies that model deployments have Prompt Shields enabled in their content filter. + + ### 🪪 Microsoft 365 — New Entra ID Checks + + Five new Entra ID checks, all contributed by @kinomakino. Thank you! + + - `entra_cross_tenant_access_default_outbound_restricted` verifies that the default cross-tenant access policy does not let every user reach every external application through B2B collaboration or B2B direct connect. + - `entra_policy_admin_self_service_password_reset_disabled` verifies that Self-Service Password Reset is disabled for administrators. + - `entra_policy_msol_powershell_blocked` verifies that the tenant authorization policy blocks the legacy MSOnline PowerShell module. + - `entra_app_registration_redirect_uris_secure` flags app registrations with HTTP, wildcard, malformed or `azurewebsites.net` redirect URIs. + - `entra_dynamic_group_member_of_rule_not_used` flags dynamic groups whose membership rule uses the `memberOf` operator, which Microsoft retires on November 3, 2026. + + Explore all checks at [Prowler Hub](https://hub.prowler.com/check). + + ### ☁️ OCI — Connect Tenancies Outside us-ashburn-1 + + OCI API key credentials now take a home region, and the credentials form in Prowler App requires it. Identity calls that validate the credentials and discover the subscribed regions used to go to `us-ashburn-1` regardless of the tenancy, so a tenancy not subscribed to that region could not connect. The home region is only used for that bootstrap; scans still audit every subscribed region. + + Read more in the [Getting Started with OCI documentation](https://docs.prowler.com/user-guide/providers/oci/getting-started-oci). + + ### 🛠️ Improvements and Fixes + + - **AWS:** Organizations metadata is fetched from the partition of the audited account, using the region that answered the STS bootstrap, instead of the commercial `us-east-1` endpoint, which timed out in GovCloud. + - API Gunicorn workers are recycled after 10,000 requests, plus up to 1,000 of jitter, to cap memory growth. Both values are configurable with `GUNICORN_MAX_REQUESTS` and `GUNICORN_MAX_REQUESTS_JITTER`. + - The first-login redirect to the Add Provider wizard is retried on the next load when the navigation was cut short, instead of being written off after a single attempt. + - The AWS connect step offers to invite a teammate when the user cannot access the account credentials. + - **Prowler Cloud:** the Slack channel picker reads the channels the Prowler app is in before the public ones, so invited private channels show up in large workspaces, and the partial list message tells the user to invite the app to a missing channel. + + ### 🔐 Security Updates + + - Kubernetes kubeconfigs accept only inline credentials and known cluster fields. File-path fields, command-based authentication, `auth-provider` directives, proxy URLs and unknown keys are rejected by the API and the credentials form, and stored kubeconfigs are re-validated before each scan and connection test. + - IaC repository URLs that resolve to loopback, private or otherwise non-public hosts are rejected before the connection test and the scan clone, including every destination a redirect points to. + - CSV outputs prefix cells starting with `=`, `+`, `-`, `@`, tab or carriage return with a single quote so spreadsheet applications treat them as text, and the HTML report escapes the compliance column. + - `GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token. + - API error reports sent to Sentry no longer include user identity, IP addresses, cookies, headers or request bodies. + - PowerShell upgraded to 7.5.12 in the SDK and API container images, bundling .NET runtime 9.0.21. + - Python base images upgraded to 3.12.15 in the SDK and API and to 3.13.16 in the MCP Server, and Trivy to 0.75.0, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy. + - `django` upgraded to 5.2.17 in the API, patching CVE-2026-15307. + - `pyjwt` upgraded to 2.14.0 in the SDK, the API and the MCP Server, patching GHSA-ffc3-869f-jxw9. + - `pymongo` upgraded to 4.18.2 and `urllib3` to 2.8.0 in the SDK and the API, patching CVE-2026-96748, CVE-2026-96749, CVE-2026-97687 and CVE-2026-97689. + - `dulwich` upgraded to 1.2.9 in the SDK and the API, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx. + - `next` upgraded to 16.3.8 in the UI, patching a remote code execution in `next/og` (GHSA-vcvr-r3jv-pc5j) and a server-side request forgery in Image Optimization (GHSA-cjq9-62q9-8jv4). + - `sharp` upgraded to 0.35.5 and `proxy-addr` to 2.0.8 in the UI, patching GHSA-wq5f-xc86-pv6w and GHSA-jqcg-44mw-7w3h. + + ### 🙌 External Contributors + + Thank you to our community contributors for this release! + + - @rajeshnadiminti: Azure AI Services service and its checks ([#12925](https://github.com/prowler-cloud/prowler/pull/12925), [#12926](https://github.com/prowler-cloud/prowler/pull/12926), [#12997](https://github.com/prowler-cloud/prowler/pull/12997)) + - @kinomakino: Microsoft 365 `entra_cross_tenant_access_default_outbound_restricted` ([#12991](https://github.com/prowler-cloud/prowler/pull/12991)), `entra_policy_admin_self_service_password_reset_disabled` ([#12990](https://github.com/prowler-cloud/prowler/pull/12990)), `entra_policy_msol_powershell_blocked` ([#13004](https://github.com/prowler-cloud/prowler/pull/13004)), `entra_app_registration_redirect_uris_secure` ([#12993](https://github.com/prowler-cloud/prowler/pull/12993)) and `entra_dynamic_group_member_of_rule_not_used` ([#13003](https://github.com/prowler-cloud/prowler/pull/13003)) checks + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.45.0) for the complete list of changes. + + ### 🔁 Findings — Re-check a Resource with a Partial Scan diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index a0b07fc982..8cc060bbd5 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -129,8 +129,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.44.0" -PROWLER_API_VERSION="5.44.0" +PROWLER_UI_VERSION="5.45.0" +PROWLER_API_VERSION="5.45.0" ``` diff --git a/docs/getting-started/products/prowler-cloud-aws-marketplace.mdx b/docs/getting-started/products/prowler-cloud-aws-marketplace.mdx index d926c9ea9b..9c972faad7 100644 --- a/docs/getting-started/products/prowler-cloud-aws-marketplace.mdx +++ b/docs/getting-started/products/prowler-cloud-aws-marketplace.mdx @@ -3,54 +3,337 @@ title: 'Subscribe to Prowler Cloud via AWS Marketplace' description: 'Subscribe to Prowler Cloud through the AWS Marketplace listing, set up your account, and manage billing for cloud security scanning directly from AWS Billing.' --- -This section contains the instructions to subscribe to **Prowler Cloud** through the **AWS Marketplace**. +This page explains how to subscribe to **Prowler Cloud** through the **AWS Marketplace**, how to activate the subscription in Prowler Cloud, how billing works, and how to fix the errors you may hit on the way. -## How to Subscribe +## Plans -To get to the **Prowler Cloud** product listing in the AWS Marketplace, and click the `View purchase options` button: +Prowler Cloud has two listings in the AWS Marketplace. Both give you the same product. They differ in how you commit and how you pay. -1. Use this link to be taken directly to the [Prowler Cloud Marketplace Listing](https://aws.amazon.com/marketplace/pp/prodview-6ochhig5kxpok): +A **provider** is a cloud account you connect to Prowler Cloud: an AWS account, an Azure subscription, a GCP project, a Kubernetes cluster, and so on. - ![](/images/aws-marketplace/marketplace-listing.png) +| | Monthly (pay as you go) | Annual (billed annually) | +| --- | --- | --- | +| Listing | [Prowler Cloud $99/provider per month (Pay as You Go)](https://aws.amazon.com/marketplace/pp/prodview-nmbvelz746oyk) | [Prowler Cloud $79/provider per month (Billed Annually)](https://aws.amazon.com/marketplace/pp/prodview-wwehsmzui25ri) | +| Billing | Monthly, per connected provider | Once, at checkout, for the full 12 months | +| Commitment | None. Cancel anytime. | 12 months for the number of providers you choose during checkout on AWS. | +| Resources beyond the included amount | Billed per resource | Billed per resource, at the annual rate | +| Providers beyond your commitment | Not applicable | Billed monthly at the pay-as-you-go rate | -2. Then, scroll down to the "Purchase Details" section and click the `Subscribe` button: +For prices, included resources and per-resource rates, see the [Prowler Cloud pricing page](https://prowler.com/pricing/). - ![](/images/aws-marketplace/marketplace-subscribe.png) +## Before You Subscribe -## Set Up Your Account +- **One AWS account pays for the whole organization.** Subscribe from a single AWS account. You then connect as many providers as you need, from any cloud, inside Prowler Cloud. You do not subscribe again for each AWS account you scan. +- **Decide which Prowler Cloud organization will pay.** Activation links the AWS purchase to the organization you are signed in to. If you belong to several organizations, switch to the right one before you activate. See [Activate the Subscription in Prowler Cloud](#activate-the-subscription-in-prowler-cloud). +- **Annual plan: decide the provider count first.** AWS asks for it during checkout, and AWS charges the full year at that moment. Providers you add beyond that count are billed monthly at the pay-as-you-go rate. AWS may not let you cancel an annual contract yourself from the console. See [Cancel the Subscription](#cancel-the-subscription) before you commit. +- **Cancel any card-billed subscription first.** An organization already billed by credit card cannot switch to AWS Marketplace billing until that subscription is cancelled. This prevents double charges. -After you have subscribed to the **Prowler Cloud** product, you will need to set up your **Prowler Cloud** account: +## Subscribe on AWS Marketplace -1. Click the `Set up your account` button: + + + Sign in to the AWS account that will pay, open the listing for the plan you want, and click **View purchase options**. - ![](/images/aws-marketplace/marketplace-message.png) + + + Open [Prowler Cloud $99/provider per month (Pay as You Go)](https://aws.amazon.com/marketplace/pp/prodview-nmbvelz746oyk). -2. You will be redirected to **Prowler Cloud Sign In** page. You can sign in with an existing account or sign up with a new account: + + Monthly Prowler Cloud listing on AWS Marketplace with the View purchase options button highlighted + + + + Open [Prowler Cloud $79/provider per month (Billed Annually)](https://aws.amazon.com/marketplace/pp/prodview-wwehsmzui25ri). - ![](/images/aws-marketplace/marketplace-sign-up.png) + + Annual Prowler Cloud listing on AWS Marketplace with the View purchase options button highlighted + + + + -3. Once you have successfully authenticated, you should be automatically redirected to the **Prowler Cloud** [Billing](https://cloud.prowler.com/billing) page where you should now see that your account has the `AWS Marketplace Subscription` badge. + + + + Scroll to **Purchase details** and click **Subscribe**. There is no upfront charge. AWS bills your usage at the end of each month. - ![](/images/aws-marketplace/marketplace-my-account.png) + + Purchase details for the monthly listing with the Subscribe button highlighted + + + + Under **Pricing details**, select the **providers** dimension and enter the **Number of units**. One unit is one provider for the 12-month term. Review the **Contract total**, then click **Subscribe** at the bottom of the page. AWS charges the full year now. -If you have any issues signing up, please contact us at support@prowler.com. + + Purchase options for the annual listing showing the contract details, the providers dimension and the Number of units table + + **Auto-renewal** is on by default. Leave it on to have AWS renew the contract for another 12 months at the end date, or choose **Do not automatically renew this contract**. You can change this from **Manage subscriptions** any time before the renewal date. + + + -## Billing + + AWS confirms the purchase and shows a **Set up your account** button. Click it. AWS sends your browser to Prowler Cloud with a setup link. -You will be charged monthly based on resources scanned and monitored depending on usage in **Prowler Cloud**. For more information on billing, please see the [Prowler Cloud Pricing FAQ](https://prowler.com/pricing/). + + AWS Marketplace purchase confirmation banner with the Set up your account button + -**Note:** Your **Prowler Cloud** bills can be seen at [AWS Billing](https://us-east-1.console.aws.amazon.com/billing/home#/bills). + + The setup link expires quickly: AWS documents roughly one hour, and never more than four. If the link expires, you close the tab, or need to start over, return to [Manage subscriptions](https://console.aws.amazon.com/marketplace/subscriptions) in the AWS Marketplace console, open **Prowler Cloud**, and click **Set up your account** again to get a fresh link. + + + -## Subscription +## Activate the Subscription in Prowler Cloud -If you subscribe to Prowler Cloud through the AWS Marketplace it is not necessary to subscribe from different AWS accounts to use Prowler Cloud for those accounts. +Nothing is linked until you confirm it in Prowler Cloud. The purchase is safe on the AWS side while you complete these steps. -In Prowler Cloud you only need to subscribe from one of your AWS accounts through the AWS Marketplace and add multiple provider accounts once you are in the Prowler Cloud console. We will send usage metrics to the AWS Marketplace regardless of the number of accounts you add in our platform, so the AWS Marketplace will bill you based on those usage metrics. + + + Prowler Cloud shows the sign-in page with a banner that reads "To finish setting up your AWS Marketplace subscription, sign in or register if you don't have an account yet." + + + Prowler Cloud sign-in page with the AWS Marketplace banner + + + If you do not have an account yet, click **Sign up**. The purchase carries over once you sign in with the new account. + + + Prowler Cloud sign-up page with the AWS Marketplace banner + + + + + The billing page shows a card titled **Activate your AWS Marketplace subscription**. It names the organization the purchase will be linked to. + + + Activation card naming the organization, with the Activate subscription button and the Continue to billing without activating link + + + + Read the organization name before you click. Activation is a billing decision: charges for that organization go to the AWS account you subscribed with. If it is not the organization you want to pay for, switch organizations first, then come back to the billing page. + + + Click **Activate subscription**. Prowler Cloud links the purchase and activates the subscription. Two short status messages appear while it works: "Linking your AWS Marketplace purchase…" and "Activating your subscription…". + + + Activation card showing the Activating your subscription progress message + + + **Continue to billing without activating** discards the setup link and leaves the organization unchanged. Use it only if you landed in the wrong organization. To activate later, get a fresh link from the AWS Marketplace console as described above. + + + + The billing page now states that you subscribed through AWS Marketplace, shows the AWS account that pays, and links to your subscription in the AWS Marketplace console. + + + Prowler Cloud billing page for an AWS Marketplace subscription, showing the paying AWS account and the link to the AWS Marketplace console + + + There is no payment method, invoice history, or cancel button on this page. AWS handles all of that. + + + +If something goes wrong during activation, see [Activation errors](#activation-errors). + +## After Activation + +- Add providers from the **Providers** page in Prowler Cloud. Every provider you add is covered by the same AWS subscription, whatever cloud it belongs to. +- All charges come from AWS. Find them under **Amazon Web Services, Inc. - Marketplace** in [AWS Billing](https://console.aws.amazon.com/billing/home#/bills). + + + AWS Billing page showing the Amazon Web Services, Inc. - Marketplace line + + +### Monthly + +AWS bills you at the end of each month for the providers connected and the resources scanned in Prowler Cloud during that month. + +### Annual + +AWS charges the full 12-month subscription once, at checkout. The providers covered by your commitment cost nothing more during the year. Your monthly AWS bill only includes usage beyond the commitment: extra providers at the pay-as-you-go rate, and resources beyond the included amount at the annual plan's per-resource rate. + +If auto-renewal is on, AWS renews the contract for another 12 months at the end date and charges the new term the same way. Turn it off from **Manage subscriptions** before the renewal date if you do not want that. + +## Cancel the Subscription + +There is no cancel button in Prowler Cloud for AWS Marketplace subscriptions. How you cancel depends on the plan, following the [AWS Marketplace cancellation rules](https://docs.aws.amazon.com/marketplace/latest/buyerguide/cancel-subscription.html). + +### Monthly + +Cancel from the AWS Marketplace console at any time: + +1. Open [Manage subscriptions](https://console.aws.amazon.com/marketplace/subscriptions) and open **Prowler Cloud**. +2. Under **Active agreements**, select the agreement and click **View details**. +3. Open the **Actions** menu and choose **Cancel subscription**. +4. Type `confirm` and choose **Yes, cancel subscription**. + + + Prowler Cloud agreement page in the AWS Marketplace console with the Actions menu open on Cancel subscription + + +Cancelling stops future charges. Usage accrued before the cancellation still appears on your next AWS bill. + +### Annual + +Open the agreement from [Manage subscriptions](https://console.aws.amazon.com/marketplace/subscriptions) first. If the **Actions** menu shows **Cancel subscription**, follow the monthly steps above. If it does not, AWS does not allow self-service cancellation for that contract: + +- **Within 48 hours of purchase**, contact AWS Customer Service. AWS can cancel the contract and refund the full amount, but only if you ask for the refund. +- **After 48 hours**, contact [support@prowler.com](mailto:support@prowler.com). Prowler starts the cancellation request through AWS Marketplace and you approve it in the AWS Marketplace console. A refund is not automatic. Ask for it when you request the cancellation. + +See [Refunds and cancellations in AWS Marketplace](https://docs.aws.amazon.com/marketplace/latest/buyerguide/buyer-refunds.html) for the AWS side of the process. + +### What happens after cancellation + +Once AWS ends the subscription, Prowler Cloud pauses scans for the organization. Providers, findings and scan history stay readable. A **Subscription required** card in the sidebar, labeled **Subscription cancelled**, explains it: "Scans are paused and your assessment data remains available. Subscribe again to continue scanning." The billing page shows the plan selection page. + + + Prowler Cloud after an AWS Marketplace cancellation, showing the Subscription cancelled card in the sidebar and the plan selection page + + +To subscribe again from the same AWS account into the same Prowler Cloud organization, subscribe from the listing and activate as before. + + +Deleting your Prowler Cloud organization does not cancel the AWS subscription. AWS keeps charging the agreement until you cancel it as described above. + ## Troubleshooting -### SEPA Payment Method Issues +### Activation errors + +Each error appears on the Prowler Cloud billing page with the title below. Your AWS purchase is never lost by any of them. **Continue to billing** closes the message without activating. + + + + AWS no longer accepts the setup link, usually because it timed out. AWS documents roughly one hour of validity, and never more than four. + + Open **Manage subscriptions** in the AWS Marketplace console, open **Prowler Cloud**, and click **Set up your account** to get a fresh link. + + + Error notice: Your AWS Marketplace link has expired + + + + + The purchase went through on AWS, but this listing is not enabled for activation in Prowler Cloud. You see this when you subscribed to an earlier Prowler Cloud listing instead of one of the [two current plans](#plans). + + Contact [support@prowler.com](mailto:support@prowler.com) with the AWS account you subscribed with. Click **Try again** once support confirms. + + + Error notice: This AWS Marketplace product is not supported yet + + + + + The purchase cannot be linked to the organization you are signed in to. The message shows the reason reported for this purchase: + + - **"This AWS Marketplace subscription is no longer active."** The subscription was cancelled on AWS. Subscribe again from the listing. If you see this seconds after a new purchase, AWS may still be registering it: wait a minute, go back to **Manage subscriptions**, and click **Set up your account** again. + - **"This AWS Marketplace purchase is already linked to another Prowler account."** AWS identifies a purchase by AWS account and product, so one AWS account backs one Prowler Cloud organization per plan. Sign in to the organization that already holds it, or subscribe from a different AWS account. + - **"This account is already linked to another active AWS Marketplace purchase."** This organization already has an active AWS Marketplace subscription. Cancel that subscription in the AWS Marketplace console before activating a new one. + + If none of these applies, contact [support@prowler.com](mailto:support@prowler.com). + + + Error notice: This AWS subscription is already linked, with the reason shown below the message + + + + + Another request is linking the same purchase, usually a second click or a second tab. Wait a moment and click **Try again**. + + + Error notice: Your purchase is still being linked + + + + + This organization is billed by credit card. Cancel that subscription first, so you are never charged twice. Then open **Manage subscriptions** in the AWS Marketplace console and click **Set up your account** again. + + + Error notice: You already have an active subscription + + + + + AWS Marketplace billing for this AWS account and plan is already set up in a different Prowler Cloud organization. This also happens after you delete the organization that held the subscription and subscribe again from a new one. + + Sign in to the organization that holds the billing and restart the setup from **Manage subscriptions** in the AWS Marketplace console. If that organization no longer exists, contact [support@prowler.com](mailto:support@prowler.com) so they can release the billing link. + + + + Prowler Cloud received no purchase to activate. Open **Manage subscriptions** in the AWS Marketplace console and click **Set up your account** to start again. + + + Error notice: We could not find your AWS purchase + + + + + A temporary problem talking to AWS. Click **Try again**. If it keeps failing, contact [support@prowler.com](mailto:support@prowler.com) and include the detail text shown under the message. + + + Error notice: We could not reach AWS Marketplace + + + + + The purchase was linked but the activation did not complete. Click **Try again**. If it keeps failing, contact [support@prowler.com](mailto:support@prowler.com) and include the detail text shown under the message. + + + Error notice: We could not activate your subscription + + + + + Your Prowler Cloud session expired before the activation finished. Click **Sign in again** and pick up where you left off. The setup link stays valid during its expiry window. + + + Error notice: Please sign in again + + + + + The setup link arrived damaged. Open **Manage subscriptions** in the AWS Marketplace console and click **Set up your account** to get a fresh one. + + + Error notice: We could not read your AWS purchase + + + + +### Errors before the sign-in page + +If the redirect from AWS itself fails, Prowler Cloud shows a plain page instead of the app. Each page links back to Prowler Cloud billing and to your AWS Marketplace subscriptions. + + + + The setup page only accepts the redirect from AWS Marketplace. You reach it by opening the setup URL directly or from a bookmark. Start again from **Set up your account** in the AWS Marketplace console. + + + Error page: We couldn't verify this request + + + + + The redirect arrived without a valid setup token. Start again from **Set up your account** in the AWS Marketplace console. + + + Error page: We couldn't read your AWS purchase + + + + + Prowler Cloud could not process the redirect. Your purchase is safe. Wait a moment, then start again from **Set up your account** in the AWS Marketplace console. Contact [support@prowler.com](mailto:support@prowler.com) if it happens again. + + + Error page: Something went wrong + + + + +### SEPA payment method issues If AWS Marketplace notifies that payment failed due to an issue with the payment method, this typically occurs when a SEPA bank account is set as the default payment method. AWS Marketplace does not support SEPA bank accounts for product subscriptions, even when the account includes valid alternative payment methods. This is because AWS Marketplace invoices are issued by AWS Inc., a US entity. SEPA accounts do not recognize these invoices as valid, causing subscription failures. @@ -74,10 +357,14 @@ The AWS Marketplace team acknowledges this limitation: "We apologize for these a -### Credit and Debit Card Storage Restrictions (AISPL Customers in India) +### Credit and debit card storage restrictions (AISPL customers in India) AWS Marketplace no longer supports payments using credit or debit cards stored on file for Amazon Internet Services Private Limited (AISPL) customers. This restriction stems from Reserve Bank of India (RBI) regulations regarding payment aggregators, which prohibit the storage of card data. As explained in [this AWS blog post](https://aws.amazon.com/blogs/awsmarketplace/restriction-on-credit-and-debit-card-purchases-for-aispl-customers-using-aws-marketplace/): > AWS Marketplace can no longer support payments using credit or debit cards stored on file. [The Reserve Bank of India (RBI) has issued a notice regarding regulation of payment aggregators](https://www.rbi.org.in/Scripts/NotificationUser.aspx?Id=11822&Mode=0), which restricts the storage of card data. If you are currently using credit or debit card as your default payment instrument, your ability to use AWS Marketplace products will be restricted. However, you can switch your default payment instrument to Pay By Invoice to avoid disruption or restore your original experience. To maintain uninterrupted access to AWS Marketplace products, change the default payment instrument from stored card data to Pay By Invoice billing. + +## Support + +If you get stuck at any step, contact [support@prowler.com](mailto:support@prowler.com) and include the AWS account you subscribed with and the exact error title you see. diff --git a/docs/images/aws-marketplace/activate-confirm.png b/docs/images/aws-marketplace/activate-confirm.png new file mode 100644 index 0000000000..5f0b4c7659 Binary files /dev/null and b/docs/images/aws-marketplace/activate-confirm.png differ diff --git a/docs/images/aws-marketplace/activate-working.png b/docs/images/aws-marketplace/activate-working.png new file mode 100644 index 0000000000..91ef40d029 Binary files /dev/null and b/docs/images/aws-marketplace/activate-working.png differ diff --git a/docs/images/aws-marketplace/aws-manage-subscriptions.png b/docs/images/aws-marketplace/aws-manage-subscriptions.png new file mode 100644 index 0000000000..0d02aad4a4 Binary files /dev/null and b/docs/images/aws-marketplace/aws-manage-subscriptions.png differ diff --git a/docs/images/aws-marketplace/billing-cancelled.png b/docs/images/aws-marketplace/billing-cancelled.png new file mode 100644 index 0000000000..01681eb706 Binary files /dev/null and b/docs/images/aws-marketplace/billing-cancelled.png differ diff --git a/docs/images/aws-marketplace/billing-subscribed.png b/docs/images/aws-marketplace/billing-subscribed.png new file mode 100644 index 0000000000..339adaf8d0 Binary files /dev/null and b/docs/images/aws-marketplace/billing-subscribed.png differ diff --git a/docs/images/aws-marketplace/discover-product.png b/docs/images/aws-marketplace/discover-product.png deleted file mode 100644 index b78f0c6fc4..0000000000 Binary files a/docs/images/aws-marketplace/discover-product.png and /dev/null differ diff --git a/docs/images/aws-marketplace/error-agreement-in-use.png b/docs/images/aws-marketplace/error-agreement-in-use.png new file mode 100644 index 0000000000..cd4387cba1 Binary files /dev/null and b/docs/images/aws-marketplace/error-agreement-in-use.png differ diff --git a/docs/images/aws-marketplace/error-billing-provider-conflict.png b/docs/images/aws-marketplace/error-billing-provider-conflict.png new file mode 100644 index 0000000000..1bfb535a02 Binary files /dev/null and b/docs/images/aws-marketplace/error-billing-provider-conflict.png differ diff --git a/docs/images/aws-marketplace/error-invalid-input.png b/docs/images/aws-marketplace/error-invalid-input.png new file mode 100644 index 0000000000..83b7d83ac8 Binary files /dev/null and b/docs/images/aws-marketplace/error-invalid-input.png differ diff --git a/docs/images/aws-marketplace/error-link-in-progress.png b/docs/images/aws-marketplace/error-link-in-progress.png new file mode 100644 index 0000000000..3015e6752e Binary files /dev/null and b/docs/images/aws-marketplace/error-link-in-progress.png differ diff --git a/docs/images/aws-marketplace/error-no-agreement.png b/docs/images/aws-marketplace/error-no-agreement.png new file mode 100644 index 0000000000..a792829d12 Binary files /dev/null and b/docs/images/aws-marketplace/error-no-agreement.png differ diff --git a/docs/images/aws-marketplace/error-product-not-supported.png b/docs/images/aws-marketplace/error-product-not-supported.png new file mode 100644 index 0000000000..97d1d0aaf3 Binary files /dev/null and b/docs/images/aws-marketplace/error-product-not-supported.png differ diff --git a/docs/images/aws-marketplace/error-resolve-failed.png b/docs/images/aws-marketplace/error-resolve-failed.png new file mode 100644 index 0000000000..2a029bf188 Binary files /dev/null and b/docs/images/aws-marketplace/error-resolve-failed.png differ diff --git a/docs/images/aws-marketplace/error-subscribe-failed.png b/docs/images/aws-marketplace/error-subscribe-failed.png new file mode 100644 index 0000000000..de7c840377 Binary files /dev/null and b/docs/images/aws-marketplace/error-subscribe-failed.png differ diff --git a/docs/images/aws-marketplace/error-token-expired.png b/docs/images/aws-marketplace/error-token-expired.png new file mode 100644 index 0000000000..3f7ffc2a8a Binary files /dev/null and b/docs/images/aws-marketplace/error-token-expired.png differ diff --git a/docs/images/aws-marketplace/error-unauthorized.png b/docs/images/aws-marketplace/error-unauthorized.png new file mode 100644 index 0000000000..084c5eff21 Binary files /dev/null and b/docs/images/aws-marketplace/error-unauthorized.png differ diff --git a/docs/images/aws-marketplace/fulfillment-error-generic.png b/docs/images/aws-marketplace/fulfillment-error-generic.png new file mode 100644 index 0000000000..dafa3e7b86 Binary files /dev/null and b/docs/images/aws-marketplace/fulfillment-error-generic.png differ diff --git a/docs/images/aws-marketplace/fulfillment-error-read.png b/docs/images/aws-marketplace/fulfillment-error-read.png new file mode 100644 index 0000000000..ceb3036a9d Binary files /dev/null and b/docs/images/aws-marketplace/fulfillment-error-read.png differ diff --git a/docs/images/aws-marketplace/fulfillment-error-verify.png b/docs/images/aws-marketplace/fulfillment-error-verify.png new file mode 100644 index 0000000000..ae11eca36e Binary files /dev/null and b/docs/images/aws-marketplace/fulfillment-error-verify.png differ diff --git a/docs/images/aws-marketplace/listing-annual.png b/docs/images/aws-marketplace/listing-annual.png new file mode 100644 index 0000000000..e9609794e9 Binary files /dev/null and b/docs/images/aws-marketplace/listing-annual.png differ diff --git a/docs/images/aws-marketplace/listing-monthly.png b/docs/images/aws-marketplace/listing-monthly.png new file mode 100644 index 0000000000..944cae4731 Binary files /dev/null and b/docs/images/aws-marketplace/listing-monthly.png differ diff --git a/docs/images/aws-marketplace/marketplace-listing.png b/docs/images/aws-marketplace/marketplace-listing.png deleted file mode 100644 index aae03ee438..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-listing.png and /dev/null differ diff --git a/docs/images/aws-marketplace/marketplace-message.png b/docs/images/aws-marketplace/marketplace-message.png deleted file mode 100644 index 7b4581273c..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-message.png and /dev/null differ diff --git a/docs/images/aws-marketplace/marketplace-my-account.png b/docs/images/aws-marketplace/marketplace-my-account.png deleted file mode 100644 index 85fc08f932..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-my-account.png and /dev/null differ diff --git a/docs/images/aws-marketplace/marketplace-sign-up.png b/docs/images/aws-marketplace/marketplace-sign-up.png deleted file mode 100644 index 249bbb6c56..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-sign-up.png and /dev/null differ diff --git a/docs/images/aws-marketplace/marketplace-subscribe.png b/docs/images/aws-marketplace/marketplace-subscribe.png deleted file mode 100644 index 033e7f156d..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-subscribe.png and /dev/null differ diff --git a/docs/images/aws-marketplace/marketplace-unsubscribe-error.png b/docs/images/aws-marketplace/marketplace-unsubscribe-error.png deleted file mode 100644 index 97c81abe71..0000000000 Binary files a/docs/images/aws-marketplace/marketplace-unsubscribe-error.png and /dev/null differ diff --git a/docs/images/aws-marketplace/my-account.png b/docs/images/aws-marketplace/my-account.png deleted file mode 100644 index 3476a7e5aa..0000000000 Binary files a/docs/images/aws-marketplace/my-account.png and /dev/null differ diff --git a/docs/images/aws-marketplace/purchase-annual.png b/docs/images/aws-marketplace/purchase-annual.png new file mode 100644 index 0000000000..83a57a912a Binary files /dev/null and b/docs/images/aws-marketplace/purchase-annual.png differ diff --git a/docs/images/aws-marketplace/purchase-monthly.png b/docs/images/aws-marketplace/purchase-monthly.png new file mode 100644 index 0000000000..488239d4bd Binary files /dev/null and b/docs/images/aws-marketplace/purchase-monthly.png differ diff --git a/docs/images/aws-marketplace/set-up-your-account.png b/docs/images/aws-marketplace/set-up-your-account.png new file mode 100644 index 0000000000..4769dfd509 Binary files /dev/null and b/docs/images/aws-marketplace/set-up-your-account.png differ diff --git a/docs/images/aws-marketplace/sign-in-marketplace.png b/docs/images/aws-marketplace/sign-in-marketplace.png new file mode 100644 index 0000000000..dc2e350d97 Binary files /dev/null and b/docs/images/aws-marketplace/sign-in-marketplace.png differ diff --git a/docs/images/aws-marketplace/sign-up-marketplace.png b/docs/images/aws-marketplace/sign-up-marketplace.png new file mode 100644 index 0000000000..8fe2f45356 Binary files /dev/null and b/docs/images/aws-marketplace/sign-up-marketplace.png differ diff --git a/docs/images/aws-marketplace/subscription-in-use.png b/docs/images/aws-marketplace/subscription-in-use.png deleted file mode 100644 index 7797170f6b..0000000000 Binary files a/docs/images/aws-marketplace/subscription-in-use.png and /dev/null differ diff --git a/docs/images/aws-marketplace/subscription-pending.png b/docs/images/aws-marketplace/subscription-pending.png deleted file mode 100644 index bb608afd68..0000000000 Binary files a/docs/images/aws-marketplace/subscription-pending.png and /dev/null differ diff --git a/docs/user-guide/providers/iac/authentication.mdx b/docs/user-guide/providers/iac/authentication.mdx index bbc7409dc3..5ca95ef991 100644 --- a/docs/user-guide/providers/iac/authentication.mdx +++ b/docs/user-guide/providers/iac/authentication.mdx @@ -12,3 +12,28 @@ Prowler's Infrastructure as Code (IaC) provider enables you to scan local or rem - [**GitHub Username and Personal Access Token (PAT)**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) - [**GitHub OAuth App Token**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) - [**Git URL**](https://git-scm.com/docs/git-clone#_git_urls) + +### Private Repository Hosts + +**Prowler CLI scans a repository on an internal network with no extra configuration.** On the CLI the operator supplies the repository URL themselves, so there is nothing to defend against and the check below does not apply. + +**Prowler App rejects a repository URL that resolves to a non-public address**, as an SSRF defense. There the URL arrives from a tenant and the worker must not be used to reach the internal network it runs in. The check covers both the connection test and the scan clone. To scan a self-hosted GitLab, Gitea or Bitbucket from Prowler App, declare the trusted ranges: + +```console +PROWLER_ALLOWED_PRIVATE_NETWORKS="10.20.0.0/16,192.168.65.254/32" +``` + +The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback and the rest of the internal network remain protected. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control. + +The scan runs in the worker, not the API, so the variable has to reach the worker: + +- **Docker Compose**: set it in the root `.env`; it reaches the worker through the shared environment file. +- **Helm**: add it under `api.djangoConfig`, which is rendered into the API ConfigMap that the worker inherits through `envFrom`. + +```yaml +api: + djangoConfig: + PROWLER_ALLOWED_PRIVATE_NETWORKS: "10.20.0.0/16" +``` + +Setting it only on the API container has no effect. The same variable applies to the Kubernetes and OpenStack providers. The Image provider has its own, `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS`. diff --git a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx index f9ae3f99b1..a75389f329 100644 --- a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx @@ -27,7 +27,7 @@ This guide covers the Slack integration in Prowler Cloud and Prowler Private Clo When connected and configured: 1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted. -2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to. +2. Prowler reads the channels it can post to: first the channels the Prowler app is already in, public or private, then the rest of the workspace's public channels. 3. Several of those channels are selected and saved as the integration's authorized channels. 4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet. 5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels. @@ -98,7 +98,7 @@ Declining the consent screen creates nothing. Prowler reports that the workspace Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace. -1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**. +1. Open the **Destination channels** selection. It lists the channels the Prowler app is already in, including the private channels it has been invited to, each marked **Private**, followed by the rest of the workspace's public channels. ![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png) @@ -109,7 +109,7 @@ Prowler validates the selection against Slack and derives each channel name itse If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` to a private one, then click **Refresh channels**. -A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out. +A workspace can hold more public channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and the channels `@Prowler Cloud` is already in are always among them, because they are read first. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a public channel the read left out. To make such a channel selectable, invite `@Prowler Cloud` to it and click **Refresh channels**. Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized. diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index 07e92992fc..4f50280aeb 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the **Prowler MCP Server** are documented in this file. +## [0.12.3] (Prowler v5.45.0) + +### 🔐 Security + +- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911) +- Python from 3.13.14 to 3.13.16 in the MCP container image, patching CVE-2026-19445 and CVE-2026-19553 [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009) + +--- + ## [0.12.2] (Prowler v5.43.0) ### 🔐 Security diff --git a/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md b/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md deleted file mode 100644 index c926794e3e..0000000000 --- a/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md +++ /dev/null @@ -1 +0,0 @@ -Python from 3.13.14 to 3.13.16 in the MCP container image, patching CVE-2026-19445 and CVE-2026-19553 diff --git a/mcp_server/changelog.d/pyjwt-2-14-0.security.md b/mcp_server/changelog.d/pyjwt-2-14-0.security.md deleted file mode 100644 index fab78f2937..0000000000 --- a/mcp_server/changelog.d/pyjwt-2-14-0.security.md +++ /dev/null @@ -1 +0,0 @@ -`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d424abdd01..5b43d32051 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.45.0] (Prowler v5.45.0) + +### 🚀 Added + +- Azure AI Services service for Azure provider, including the `aiservices_account_public_network_access_disabled`, `aiservices_account_local_auth_disabled`, and `aiservices_account_encrypted_with_cmk` checks [(#12925)](https://github.com/prowler-cloud/prowler/pull/12925) +- `aiservices_account_uses_private_endpoint`, `aiservices_account_outbound_network_restricted`, `aiservices_account_managed_identity_enabled`, and `aiservices_account_diagnostic_logging_enabled` checks for Azure provider [(#12926)](https://github.com/prowler-cloud/prowler/pull/12926) +- `entra_policy_admin_self_service_password_reset_disabled` check for M365 provider, verifying that Self-Service Password Reset is disabled for administrators [(#12990)](https://github.com/prowler-cloud/prowler/pull/12990) +- `entra_cross_tenant_access_default_outbound_restricted` check for M365 provider, verifying that default cross-tenant access does not let all users reach all external applications through B2B collaboration or B2B direct connect [(#12991)](https://github.com/prowler-cloud/prowler/pull/12991) +- `entra_app_registration_redirect_uris_secure` check for M365 provider, flagging app registrations with HTTP, wildcard, malformed or `azurewebsites.net` redirect URIs [(#12993)](https://github.com/prowler-cloud/prowler/pull/12993) +- `aiservices_account_network_acls_default_action_deny` and `aiservices_deployment_content_filter_prompt_shield_enabled` checks for Azure provider [(#12997)](https://github.com/prowler-cloud/prowler/pull/12997) +- `entra_dynamic_group_member_of_rule_not_used` check for M365 provider, flagging dynamic groups whose membership rule uses the `memberOf` operator that Microsoft retires on November 3, 2026 [(#13003)](https://github.com/prowler-cloud/prowler/pull/13003) +- `entra_policy_msol_powershell_blocked` check for M365 provider, verifying that the tenant authorization policy blocks the legacy MSOnline PowerShell module [(#13004)](https://github.com/prowler-cloud/prowler/pull/13004) + +### 🐞 Fixed + +- OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865) +- AWS Organizations metadata is fetched from the partition of the audited account, using the region that answered the STS bootstrap, instead of the commercial `us-east-1` endpoint a session without a region resolves to, which timed out in GovCloud [(#12988)](https://github.com/prowler-cloud/prowler/pull/12988) + +### 🔐 Security + +- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911) +- `dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953) +- `urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953) +- `pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 [(#12955)](https://github.com/prowler-cloud/prowler/pull/12955) +- IaC repository URLs resolving to loopback, private or otherwise non-public hosts rejected before both the connection test and the scan clone, including every destination a redirect points to, with raw errors no longer echoed to the caller or the logs [(#12960)](https://github.com/prowler-cloud/prowler/pull/12960) +- HTML report compliance column is now escaped, so framework names and requirement IDs containing markup are shown as text instead of being rendered by the browser [(#12963)](https://github.com/prowler-cloud/prowler/pull/12963) +- CSV outputs (findings, compliance and AWS quick inventory) prefix cells starting with `=`, `+`, `-`, `@`, tab or carriage return with a single quote so spreadsheet applications treat them as text instead of formulas; signed numbers such as `-1` are left untouched [(#12963)](https://github.com/prowler-cloud/prowler/pull/12963) +- Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the SDK container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009) +- PowerShell from 7.5.11 to 7.5.12 in the SDK container image, bundling .NET runtime 9.0.21 [(#13037)](https://github.com/prowler-cloud/prowler/pull/13037) + +--- + ## [5.44.0] (Prowler v5.44.0) ### 🚀 Added diff --git a/prowler/__main__.py b/prowler/__main__.py index b9de47c07b..669c1c81b9 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import os import sys import tempfile from os import environ @@ -55,6 +56,7 @@ from prowler.lib.check.custom_checks_metadata import ( from prowler.lib.check.models import CheckMetadata from prowler.lib.cli.parser import ProwlerArgumentParser from prowler.lib.logger import logger, set_logging_config +from prowler.lib.network.ssrf import SKIP_OUTBOUND_CHECK_ENV from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import ( ASDEssentialEightAWS, @@ -236,6 +238,11 @@ def _send_ocsf_to_cloud(file_path: str) -> dict | None: def prowler(): + # On the CLI the operator supplies the target themselves, so the outbound host + # check defends nothing and would refuse their own private network. setdefault, + # so an operator who set the variable explicitly keeps their choice. + os.environ.setdefault(SKIP_OUTBOUND_CHECK_ENV, "true") + # Parse Arguments # Refactor(CLI) parser = ProwlerArgumentParser() diff --git a/prowler/changelog.d/aws-organizations-partition-region.fixed.md b/prowler/changelog.d/aws-organizations-partition-region.fixed.md deleted file mode 100644 index 580a69ec05..0000000000 --- a/prowler/changelog.d/aws-organizations-partition-region.fixed.md +++ /dev/null @@ -1 +0,0 @@ -AWS Organizations metadata is fetched from the partition of the audited account, using the region that answered the STS bootstrap, instead of the commercial `us-east-1` endpoint a session without a region resolves to, which timed out in GovCloud diff --git a/prowler/changelog.d/dulwich-1-2-9.security.md b/prowler/changelog.d/dulwich-1-2-9.security.md deleted file mode 100644 index 8f1dd24253..0000000000 --- a/prowler/changelog.d/dulwich-1-2-9.security.md +++ /dev/null @@ -1 +0,0 @@ -`dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx diff --git a/prowler/changelog.d/gcp-vertexai-workbench-checks.added.md b/prowler/changelog.d/gcp-vertexai-workbench-checks.added.md new file mode 100644 index 0000000000..99fb7a246c --- /dev/null +++ b/prowler/changelog.d/gcp-vertexai-workbench-checks.added.md @@ -0,0 +1 @@ +Vertex AI service for GCP provider, including the `vertexai_workbench_instance_public_ip_disabled` and `vertexai_workbench_instance_root_access_disabled` checks mapped to PCI DSS 4.0 diff --git a/prowler/changelog.d/oci-home-region-bootstrap.fixed.md b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md deleted file mode 100644 index de2997773d..0000000000 --- a/prowler/changelog.d/oci-home-region-bootstrap.fixed.md +++ /dev/null @@ -1 +0,0 @@ -OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/prowler/changelog.d/pyjwt-2-14-0.security.md b/prowler/changelog.d/pyjwt-2-14-0.security.md deleted file mode 100644 index fab78f2937..0000000000 --- a/prowler/changelog.d/pyjwt-2-14-0.security.md +++ /dev/null @@ -1 +0,0 @@ -`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/prowler/changelog.d/pymongo-4-18-2.security.md b/prowler/changelog.d/pymongo-4-18-2.security.md deleted file mode 100644 index b621313d25..0000000000 --- a/prowler/changelog.d/pymongo-4-18-2.security.md +++ /dev/null @@ -1 +0,0 @@ -`pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 diff --git a/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md b/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md deleted file mode 100644 index 0a54879ed0..0000000000 --- a/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md +++ /dev/null @@ -1 +0,0 @@ -Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the SDK container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy diff --git a/prowler/changelog.d/sdk-image-trivy-0-76-0.security.md b/prowler/changelog.d/sdk-image-trivy-0-76-0.security.md new file mode 100644 index 0000000000..99fd4c2c70 --- /dev/null +++ b/prowler/changelog.d/sdk-image-trivy-0-76-0.security.md @@ -0,0 +1 @@ +Trivy from 0.75.0 to 0.76.0 in the SDK container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 diff --git a/prowler/changelog.d/urllib3-2-8-0.security.md b/prowler/changelog.d/urllib3-2-8-0.security.md deleted file mode 100644 index 4645bb5942..0000000000 --- a/prowler/changelog.d/urllib3-2-8-0.security.md +++ /dev/null @@ -1 +0,0 @@ -`urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 diff --git a/prowler/compliance/gcp/pci_4.0_gcp.json b/prowler/compliance/gcp/pci_4.0_gcp.json index e8e30a97e1..6bb7cd000d 100644 --- a/prowler/compliance/gcp/pci_4.0_gcp.json +++ b/prowler/compliance/gcp/pci_4.0_gcp.json @@ -701,7 +701,9 @@ "Id": "1.2.8.37", "Description": "Checks if direct internet access is disabled for a Google AI Platform (formerly AI Platform Notebooks) instance. The rule is NON_COMPLIANT if an AI Platform notebook instance is internet-enabled.", "Name": "Google AI Platform", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "1.2.8: Network security controls (NSCs) are configured and maintained.", @@ -1358,7 +1360,9 @@ "Id": "1.3.1.41", "Description": "Checks if direct internet access is disabled for a Google AI Platform Notebook instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google AI Platform", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "1.3.1: Network access to and from the cardholder data environment is restricted.", @@ -2029,7 +2033,9 @@ "Id": "1.3.2.41", "Description": "Checks if direct internet access is disabled for a Google AI Platform (formerly known as AI Platform Notebooks) instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google AI Platform", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "1.3.2: Network access to and from the cardholder data environment is restricted.", @@ -2805,7 +2811,9 @@ "Id": "1.4.2.39", "Description": "Checks if direct internet access is disabled for a Google Cloud AI Platform Notebooks instance. The rule is NON_COMPLIANT if an AI Platform Notebooks instance is internet-enabled.", "Name": "Google Cloud AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "1.4.2: Network connections between trusted and untrusted networks are controlled.", @@ -3616,7 +3624,9 @@ "Id": "1.5.1.37", "Description": "Checks if direct internet access is disabled for a Google Cloud Vertex AI Workbench instance. The rule is NON_COMPLIANT if the Vertex AI Workbench instance is internet-enabled.", "Name": "Google Cloud Vertex AI Workbench", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "1.5.1: Risks to the CDE from computing devices that are able to connect to both untrusted networks and the CDE are mitigated.", @@ -8491,7 +8501,9 @@ "Id": "10.3.2.25", "Description": "Checks if direct internet access is disabled for a Google AI Platform Notebook instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "10.3.2: Audit logs are protected from destruction and unauthorized modifications.", @@ -12122,7 +12134,9 @@ "Id": "3.5.1.3.30", "Description": "Checks if direct internet access is disabled for a Google Cloud AI Platform Notebooks instance. The rule is NON_COMPLIANT if an AI Platform Notebooks instance is internet-enabled.", "Name": "Google Cloud AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "3.5.1.3: Primary account number (PAN) is secured wherever it is stored.", @@ -15753,7 +15767,9 @@ "Id": "7.2.1.28", "Description": "Checks if the Google AI Platform Notebooks Root Access setting is enabled for AI Platform Notebook instances. The rule is NON_COMPLIANT if the Root Access setting is set to 'Enabled' for an AI Platform Notebook instance.", "Name": "Google AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_root_access_disabled" + ], "Attributes": [ { "Section": "7.2.1: Access to system components and data is appropriately defined and assigned.", @@ -16153,7 +16169,9 @@ "Id": "7.2.2.28", "Description": "Checks if the Google AI Platform Notebook instance has root access enabled. The rule is NON_COMPLIANT if the root access is set to 'Enabled' for a Google AI Platform Notebook instance.", "Name": "Google AI Platform", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_root_access_disabled" + ], "Attributes": [ { "Section": "7.2.2: Access to system components and data is appropriately defined and assigned.", @@ -16282,7 +16300,9 @@ "Id": "7.2.3.9", "Description": "Checks if the Vertex AI Workbench Root Access setting is enabled for Vertex AI Workbench Notebooks. The rule is NON_COMPLIANT if the Root Access setting is set to 'Enabled' for a Vertex AI Workbench Notebook instance.", "Name": "Vertex AI", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_root_access_disabled" + ], "Attributes": [ { "Section": "7.2.3: Access to system components and data is appropriately defined and assigned.", @@ -20547,7 +20567,9 @@ "Id": "A1.1.2.21", "Description": "Checks if direct internet access is disabled for a Google AI Platform Notebook instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "A1.1.2: Multi-tenant service providers protect and separate all customer environments and data.", @@ -21060,7 +21082,9 @@ "Id": "A1.1.3.37", "Description": "Checks if direct internet access is disabled for a Google Cloud AI Platform Notebook instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google Cloud AI Platform", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "A1.1.3: Multi-tenant service providers protect and separate all customer environments and data.", @@ -22180,7 +22204,9 @@ "Id": "A3.4.1.23", "Description": "Checks if direct internet access is disabled for a Google Cloud AI Platform Notebook instance. The rule is NON_COMPLIANT if an AI Platform Notebook instance is internet-enabled.", "Name": "Google Cloud AI Platform Notebooks", - "Checks": [], + "Checks": [ + "vertexai_workbench_instance_public_ip_disabled" + ], "Attributes": [ { "Section": "A3.4.1: Logical access to the cardholder data environment is controlled and managed.", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index e7bf650ac7..5d5f024d71 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -174,7 +174,9 @@ } ], "Checks": [ + "entra_app_registration_redirect_uris_secure", "entra_conditional_access_policy_app_enforced_restrictions", + "entra_cross_tenant_access_default_outbound_restricted", "exchange_transport_config_smtp_auth_disabled", "exchange_transport_rules_mail_forwarding_disabled", "exchange_transport_rules_whitelist_disabled", @@ -207,6 +209,8 @@ "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_directory_sync_account_excluded", "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_cross_tenant_access_default_outbound_restricted", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -256,6 +260,7 @@ "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", + "entra_policy_admin_self_service_password_reset_disabled", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled", @@ -279,6 +284,7 @@ "Checks": [ "entra_admin_portals_access_restriction", "entra_app_registration_no_unused_privileged_permissions", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "sharepoint_external_sharing_managed", @@ -672,8 +678,10 @@ "entra_break_glass_account_fido2_security_key_registered", "entra_emergency_access_exclusion", "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_admin_self_service_password_reset_disabled", "entra_policy_ensure_default_user_cannot_create_tenants", "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_msol_powershell_blocked", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles" ] @@ -731,6 +739,7 @@ "entra_emergency_access_exclusion", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", + "entra_policy_admin_self_service_password_reset_disabled", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled" @@ -830,6 +839,7 @@ ], "Checks": [ "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_msol_powershell_blocked", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "teams_external_domains_restricted", @@ -925,6 +935,7 @@ "admincenter_users_admins_reduced_license_footprint", "defender_malware_policy_comprehensive_attachments_filter_applied", "entra_app_registration_no_unused_privileged_permissions", + "entra_app_registration_redirect_uris_secure", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "sharepoint_modern_authentication_required" diff --git a/prowler/config/config.py b/prowler/config/config.py index aa85d98502..d9c86828e1 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -52,7 +52,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.45.0" +prowler_version = "5.46.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/prowler/lib/network/__init__.py b/prowler/lib/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/network/ssrf.py b/prowler/lib/network/ssrf.py new file mode 100644 index 0000000000..691af46764 --- /dev/null +++ b/prowler/lib/network/ssrf.py @@ -0,0 +1,166 @@ +"""Outbound URL validation for provider connection tests.""" + +from __future__ import annotations + +import ipaddress +import os +import re +import socket +from urllib.parse import urlparse + +from prowler.lib.logger import logger + +ALLOWED_PRIVATE_NETWORKS_ENV = "PROWLER_ALLOWED_PRIVATE_NETWORKS" + +# The check stays on unless a host process opts out. `prowler/__main__.py` opts the +# CLI out, because there the operator and the person supplying the URL are the same +# and there is no SSRF boundary to defend. Anything else -- Prowler App's API and +# worker above all -- keeps the check, so a missing setting fails safe. +SKIP_OUTBOUND_CHECK_ENV = "PROWLER_SKIP_OUTBOUND_HOST_CHECK" + +_TRUTHY = {"1", "true", "yes", "on"} + +_NON_PUBLIC_IP_PROPERTIES = ( + "is_private", + "is_loopback", + "is_link_local", + "is_multicast", + "is_reserved", + "is_unspecified", +) + +# scp-like git remotes (user@host:path) carry no scheme, so urlparse cannot read them +_SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?(?P[^:/]+):(?!//)") + +_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") + + +class OutboundURLNotAllowedError(Exception): + """A supplied URL points at a destination the worker must not reach.""" + + +def _parse_allowed_networks(raw: str | None) -> tuple: + if not raw or not raw.strip(): + return () + networks = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + networks.append(ipaddress.ip_network(entry, strict=False)) + except ValueError as error: + raise OutboundURLNotAllowedError( + f"Malformed entry {entry!r} in {ALLOWED_PRIVATE_NETWORKS_ENV}: {error}" + ) + return tuple(networks) + + +def allowed_private_networks() -> tuple: + """Operator-configured private networks the SSRF guard must not block.""" + networks = _parse_allowed_networks(os.environ.get(ALLOWED_PRIVATE_NETWORKS_ENV)) + if networks: + logger.warning( + f"{ALLOWED_PRIVATE_NETWORKS_ENV} is set — SSRF protection relaxed for private networks: " + + ", ".join(str(network) for network in networks) + ) + return networks + + +def outbound_check_skipped() -> bool: + """Whether this process asked to skip the non-public destination check.""" + return os.environ.get(SKIP_OUTBOUND_CHECK_ENV, "").strip().lower() in _TRUTHY + + +def _unwrap_ipv6(address: ipaddress._BaseAddress) -> ipaddress._BaseAddress: + if not isinstance(address, ipaddress.IPv6Address): + return address + embedded = address.ipv4_mapped or address.sixtofour + if embedded is None and address in _NAT64_WELL_KNOWN_PREFIX: + embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF) + return embedded or address + + +def _ip_is_non_public(address: str) -> bool: + try: + parsed = _unwrap_ipv6(ipaddress.ip_address(address)) + except ValueError: + return False + # is_global is the broad check; the properties stay because some multicast + # ranges report is_global and would otherwise slip through + if not parsed.is_global: + return True + return any(getattr(parsed, prop) for prop in _NON_PUBLIC_IP_PROPERTIES) + + +def _ip_is_allowlisted(address: str, networks: tuple) -> bool: + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return False + return any( + parsed.version == network.version and parsed in network for network in networks + ) + + +def _resolve(host: str) -> set: + try: + return {sockaddr[0] for *_, sockaddr in socket.getaddrinfo(host, None)} + except socket.gaierror as error: + raise OutboundURLNotAllowedError(f"Could not resolve host {host!r}: {error}") + + +def extract_host(url: str) -> str: + """Host of a URL, accepting scp-like git remotes that carry no scheme.""" + scp_like = _SCP_LIKE_REMOTE.match(url) + if scp_like and "://" not in url: + return scp_like.group("host") + host = urlparse(url).hostname + if not host: + # The URL itself stays out of the message: a configured repository or auth + # URL can carry credentials in its userinfo, and this message is logged + raise OutboundURLNotAllowedError("Could not read a host from the supplied URL") + return host + + +def validate_outbound_host(host: str) -> None: + """Reject a host that is, or resolves to, a non-public address. + + Resolution happens here and again inside the client that connects, so a + hostile DNS server can still answer differently the second time. + """ + if outbound_check_skipped(): + logger.warning( + f"{SKIP_OUTBOUND_CHECK_ENV} is set — destination check disabled for host {host!r}" + ) + return + + networks = allowed_private_networks() + + try: + ipaddress.ip_address(host) + except ValueError: + addresses = _resolve(host) + else: + addresses = {host} + + for address in addresses: + if _ip_is_non_public(address) and not _ip_is_allowlisted(address, networks): + raise OutboundURLNotAllowedError( + f"Host {host!r} resolves to non-public address {address} and cannot be " + f"reached. To scan a target on a private network, list the trusted " + f"ranges in the {ALLOWED_PRIVATE_NETWORKS_ENV} environment variable of " + f"the process running the scan" + ) + + +def validate_outbound_url( + url: str, *, allowed_schemes: tuple = ("http", "https") +) -> None: + """Reject a URL whose scheme is not allowed or whose host is not public.""" + scheme = urlparse(url).scheme + if scheme and scheme not in allowed_schemes: + raise OutboundURLNotAllowedError( + f"Disallowed URL scheme {scheme!r}. Allowed: {', '.join(allowed_schemes)}" + ) + validate_outbound_host(extract_host(url)) diff --git a/prowler/lib/outputs/compliance/compliance_output.py b/prowler/lib/outputs/compliance/compliance_output.py index f4f84561f4..60fc5d6749 100644 --- a/prowler/lib/outputs/compliance/compliance_output.py +++ b/prowler/lib/outputs/compliance/compliance_output.py @@ -6,6 +6,7 @@ from prowler.lib.check.compliance_models import Compliance from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.output import Output +from prowler.lib.outputs.utils import sanitize_csv_value class ComplianceOutput(Output): @@ -83,7 +84,10 @@ class ComplianceOutput(Output): csv_writer.writeheader() for finding in self._data: csv_writer.writerow( - {k.upper(): v for k, v in finding.dict().items()} + { + k.upper(): sanitize_csv_value(v) + for k, v in finding.dict().items() + } ) if self.close_file or self._from_cli: self._file_descriptor.close() diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 9c7c76da47..505e855b57 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -11,6 +11,7 @@ from prowler.lib.check.compliance_config_eval import ( ) from prowler.lib.check.compliance_models import ComplianceFramework from prowler.lib.logger import logger +from prowler.lib.outputs.utils import sanitize_csv_value from prowler.lib.utils.utils import open_file if TYPE_CHECKING: @@ -318,7 +319,12 @@ class UniversalComplianceOutput: if self._file_descriptor.tell() == 0: csv_writer.writeheader() for row in self._data: - csv_writer.writerow({k.upper(): v for k, v in row.dict().items()}) + csv_writer.writerow( + { + k.upper(): sanitize_csv_value(v) + for k, v in row.dict().items() + } + ) if self.close_file or self._from_cli: self._file_descriptor.close() except Exception as error: diff --git a/prowler/lib/outputs/csv/csv.py b/prowler/lib/outputs/csv/csv.py index bcb50d9433..1c10b20af7 100644 --- a/prowler/lib/outputs/csv/csv.py +++ b/prowler/lib/outputs/csv/csv.py @@ -4,7 +4,7 @@ from typing import List from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.output import Output -from prowler.lib.outputs.utils import unroll_dict, unroll_list +from prowler.lib.outputs.utils import sanitize_csv_value, unroll_dict, unroll_list class CSV(Output): @@ -106,7 +106,9 @@ class CSV(Output): if self._file_descriptor.tell() == 0: csv_writer.writeheader() for finding in self._data: - csv_writer.writerow(finding) + csv_writer.writerow( + {k: sanitize_csv_value(v) for k, v in finding.items()} + ) if self.close_file or self._from_cli: self._file_descriptor.close() except Exception as error: diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 60428bede7..da3faa8b1c 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -116,7 +116,7 @@ class HTML(Output): {str(escape(finding.status_extended)).replace("_", "_")}

{HTML.process_markdown(str(escape(finding.metadata.Risk)))}

{HTML.process_markdown(str(escape(finding.metadata.Remediation.Recommendation.Text)))}

-

{parse_html_string(unroll_dict(finding.compliance, separator=": "))}

+

{parse_html_string(str(escape(unroll_dict(finding.compliance, separator=": "))))}

""") except Exception as error: diff --git a/prowler/lib/outputs/utils.py b/prowler/lib/outputs/utils.py index 9750c4f7e2..de0ffce5c3 100644 --- a/prowler/lib/outputs/utils.py +++ b/prowler/lib/outputs/utils.py @@ -1,3 +1,6 @@ +from math import isfinite + + def unroll_list(listed_items: list, separator: str = "|") -> str: """ Unrolls a list of items into a single string, separated by a specified separator. @@ -199,3 +202,21 @@ def parse_html_string(str: str) -> str: string += f"\n•{elem}\n" return string + + +def sanitize_csv_value(value): + """Prefix a cell with `'` when a spreadsheet would evaluate it as a formula; numbers are left as they are.""" + if not isinstance(value, str) or not ( + value.startswith(("\t", "\r")) + or value.lstrip().startswith(("=", "+", "-", "@")) + ): + return value + try: + number = float(value) + except ValueError: + return f"'{value}" + # float() also accepts nan/inf and whitespace-padded forms, which a spreadsheet + # renders as text rather than as a number + if isfinite(number) and value == value.strip(): + return value + return f"'{value}" diff --git a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py index 8ffdd80e7b..07327aac76 100644 --- a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py +++ b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py @@ -14,6 +14,7 @@ from prowler.config.config import ( output_file_timestamp, ) from prowler.lib.logger import logger +from prowler.lib.outputs.utils import sanitize_csv_value from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.arn.models import get_arn_resource_type @@ -293,7 +294,7 @@ def create_output(resources: list, provider: AwsProvider, args): header = data.keys() csv_writer.writerow(header) count += 1 - csv_writer.writerow(data.values()) + csv_writer.writerow([sanitize_csv_value(v) for v in data.values()]) csv_file.close() print( diff --git a/prowler/providers/azure/services/aiservices/__init__.py b/prowler/providers/azure/services/aiservices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.metadata.json new file mode 100644 index 0000000000..230ea97743 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_diagnostic_logging_enabled", + "CheckTitle": "AI services account sends audit logs through a diagnostic setting", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) emit resource logs only when a **diagnostic setting** routes them to Log Analytics, Storage, or Event Hubs. This check passes when the `Audit` category, or the `audit` or `allLogs` category group, is enabled.", + "Risk": "Without audit logs, data-plane activity on the account leaves no record:\n- **Detection**: key misuse, unusual callers, and prompt abuse go unnoticed\n- **Investigation**: incident responders cannot reconstruct who called which model and when\n- **Compliance**: audit-trail requirements for AI workloads are not met", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/diagnostic-logging", + "https://learn.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings" + ], + "Remediation": { + "Code": { + "CLI": "az monitor diagnostic-settings create --name --resource --workspace --logs '[{\"categoryGroup\":\"audit\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Send audit logs from an Azure AI services account to Log Analytics\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = {\n name: ''\n}\n\nresource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: account\n properties: {\n workspaceId: ''\n logs: [\n {\n categoryGroup: 'audit' // CRITICAL: sends audit logs\n enabled: true\n }\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, open the AI services account and go to Monitoring > Diagnostic settings\n2. Click + Add diagnostic setting\n3. Under Category groups, select audit (or allLogs)\n4. Choose a destination such as a Log Analytics workspace and click Save", + "Terraform": "```hcl\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = azurerm_cognitive_account..id\n log_analytics_workspace_id = azurerm_log_analytics_workspace..id\n\n enabled_log {\n category_group = \"audit\" # CRITICAL: sends audit logs\n }\n}\n```" + }, + "Recommendation": { + "Text": "Add a **diagnostic setting** to each AI services account that sends the **audit** category group to a **Log Analytics workspace**, and alert on unusual activity. Add **RequestResponse** logs where your data-handling policy allows it.", + "Url": "https://hub.prowler.com/check/aiservices_account_diagnostic_logging_enabled" + } + }, + "Categories": [ + "logging", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.py new file mode 100644 index 0000000000..2e89cb0e40 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled.py @@ -0,0 +1,64 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + +AUDIT_CATEGORY = "audit" +AUDIT_CATEGORY_GROUPS = {"audit", "alllogs"} + + +def sends_audit_logs(diagnostic_setting) -> bool: + """Tell whether a diagnostic setting sends AI services audit logs. + + Args: + diagnostic_setting: Monitor `DiagnosticSetting` of the account. + + Returns: + True if the `Audit` category, or the `audit` or `allLogs` category + group, is enabled. + """ + return any( + log.enabled + and ( + (log.category or "").lower() == AUDIT_CATEGORY + or (log.category_group or "").lower() in AUDIT_CATEGORY_GROUPS + ) + for log in diagnostic_setting.logs + ) + + +class aiservices_account_diagnostic_logging_enabled(Check): + """AI services account sends audit logs through a diagnostic setting.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate audit logging on every AI services account. + + Returns: + One finding per account. MANUAL when the diagnostic settings + could not be read. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + settings = account.monitor_diagnostic_settings + if settings is None: + report.status = "MANUAL" + report.status_extended = f"{prefix} diagnostic settings could not be read; grant Microsoft.Insights/diagnosticSettings/read (included in Reader) to evaluate audit logging." + elif any(sends_audit_logs(setting) for setting in settings): + report.status = "PASS" + report.status_extended = ( + f"{prefix} has a diagnostic setting that sends audit logs." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} has no diagnostic setting that sends audit logs." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..d95ea2d2e1 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_encrypted_with_cmk", + "CheckTitle": "AI services account is encrypted with a customer-managed key", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) encrypt stored data such as fine-tuning files, custom models, and batch inputs. Setting the encryption **key source** to `Microsoft.KeyVault` uses a **customer-managed key** held in Azure Key Vault instead of a Microsoft-managed key.", + "Risk": "With Microsoft-managed keys the organization cannot:\n- **Revoke** access to stored AI data by disabling its own key\n- **Rotate** keys on its own schedule or audit key usage in Key Vault\n- **Meet** regulatory requirements that mandate customer-controlled encryption keys", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/encryption/cognitive-services-encryption-keys-portal", + "https://learn.microsoft.com/en-us/azure/foundry-classic/openai/encrypt-data-at-rest" + ], + "Remediation": { + "Code": { + "CLI": "az cognitiveservices account update --name --resource-group --encryption '{\"keySource\":\"Microsoft.KeyVault\",\"keyVaultProperties\":{\"keyVaultUri\":\"\",\"keyName\":\"\",\"keyVersion\":\"\"}}'", + "NativeIaC": "```bicep\n// Encrypt an Azure AI services account with a customer-managed key\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n identity: { type: 'SystemAssigned' }\n properties: {\n customSubDomainName: ''\n encryption: {\n keySource: 'Microsoft.KeyVault' // CRITICAL: use a customer-managed key\n keyVaultProperties: {\n keyName: ''\n keyVaultUri: ''\n }\n }\n }\n}\n```", + "Other": "1. Create a key in an Azure Key Vault with soft delete and purge protection enabled\n2. Enable a managed identity on the AI services account and grant it get, wrapKey, and unwrapKey on the key\n3. In the Azure portal, open the AI services account and go to Resource Management > Encryption\n4. Select Customer Managed Keys, choose the key, and click Save", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account_customer_managed_key\" \"\" {\n cognitive_account_id = azurerm_cognitive_account..id\n key_vault_key_id = azurerm_key_vault_key..id # CRITICAL: use a customer-managed key\n}\n```" + }, + "Recommendation": { + "Text": "Encrypt AI services accounts that store sensitive data with a **customer-managed key** in Azure Key Vault. Enable **soft delete** and **purge protection** on the vault, grant the account's **managed identity** only the key permissions it needs, and rotate the key on a defined schedule.", + "Url": "https://hub.prowler.com/check/aiservices_account_encrypted_with_cmk" + } + }, + "Categories": [ + "encryption", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py new file mode 100644 index 0000000000..a451d3993f --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py @@ -0,0 +1,53 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) +from prowler.providers.azure.services.aiservices.aiservices_service import Account + +KEY_VAULT_KEY_SOURCE = "Microsoft.KeyVault" + + +def uses_customer_managed_key(account: Account) -> bool: + """Decide whether the account is encrypted with a customer-managed key. + + Args: + account: AI services account. + + Returns: + True when the account counts as encrypted with a customer-managed key. + A Key Vault key source passes even when no key name is reported. + """ + return (account.encryption_key_source or "").lower() == KEY_VAULT_KEY_SOURCE.lower() + + +class aiservices_account_encrypted_with_cmk(Check): + """AI services account is encrypted with a customer-managed key.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate encryption key source on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if uses_customer_managed_key(account): + report.status = "PASS" + if account.encryption_key_name: + report.status_extended = f"{prefix} is encrypted with customer-managed key {account.encryption_key_name}." + else: + report.status_extended = f"{prefix} is encrypted with a customer-managed key from Azure Key Vault." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} is encrypted with a Microsoft-managed key." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json new file mode 100644 index 0000000000..8868b353eb --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_local_auth_disabled", + "CheckTitle": "AI services account has local (API key) authentication disabled", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) accept static **API keys** unless `disableLocalAuth` is `true`. With local authentication disabled, every request must use a **Microsoft Entra ID** token.", + "Risk": "Static API keys never expire and carry no caller identity:\n- **Confidentiality**: a leaked key grants full data-plane access to models and data\n- **Accountability**: requests cannot be traced to a user or workload\n- **Control**: Conditional Access, RBAC scoping, and token lifetimes do not apply to keys", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/disable-local-auth", + "https://learn.microsoft.com/en-us/azure/ai-services/authentication" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.disableLocalAuth=true", + "NativeIaC": "```bicep\n// Require Microsoft Entra ID for an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n disableLocalAuth: true // CRITICAL: rejects API keys, requires Entra ID tokens\n }\n}\n```", + "Other": "1. Move every client to Microsoft Entra ID authentication (managed identity or service principal with the Cognitive Services User role)\n2. Run the Azure CLI command above, or deploy the Bicep or Terraform example\n3. Confirm that requests sent with an API key now return 401", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n local_auth_enabled = false # CRITICAL: rejects API keys, requires Entra ID tokens\n}\n```" + }, + "Recommendation": { + "Text": "Set `disableLocalAuth` to `true` and authenticate clients with **managed identities** or service principals through **Microsoft Entra ID**. Grant least-privilege roles such as **Cognitive Services OpenAI User** instead of sharing keys.", + "Url": "https://hub.prowler.com/check/aiservices_account_local_auth_disabled" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py new file mode 100644 index 0000000000..982d3b663a --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_local_auth_disabled(Check): + """AI services account has local (API key) authentication disabled.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate local authentication on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if account.disable_local_auth: + report.status = "PASS" + report.status_extended = f"{prefix} has local (API key) authentication disabled and requires Microsoft Entra ID." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} allows local (API key) authentication." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.metadata.json new file mode 100644 index 0000000000..620a399a67 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_managed_identity_enabled", + "CheckTitle": "AI services account has a managed identity", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) can use a system-assigned or user-assigned **managed identity** to reach other Azure resources, such as Key Vault for customer-managed keys or Storage and AI Search for data sources, without stored credentials.", + "Risk": "Without a managed identity, the account cannot authenticate to other Azure resources through Microsoft Entra ID:\n- **Credentials**: integrations fall back to connection strings and keys that must be stored and rotated\n- **Encryption**: customer-managed keys cannot be configured, because they need an identity\n- **Auditability**: access by the service is not tied to an Entra ID principal", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview", + "https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/managed-identity" + ], + "Remediation": { + "Code": { + "CLI": "az cognitiveservices account identity assign --name --resource-group ", + "NativeIaC": "```bicep\n// Enable a system-assigned managed identity on an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n identity: { type: 'SystemAssigned' } // CRITICAL: gives the account an Entra ID identity\n properties: {\n customSubDomainName: ''\n }\n}\n```", + "Other": "1. In the Azure portal, open the AI services account and go to Resource Management > Identity\n2. On the System assigned tab, set Status to On and click Save, or add a user-assigned identity\n3. Grant the identity least-privilege roles on the resources it must reach", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n identity {\n type = \"SystemAssigned\" # CRITICAL: gives the account an Entra ID identity\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable a **managed identity** on AI services accounts and use it, instead of keys or connection strings, to reach **Key Vault**, **Storage**, and **AI Search**. Grant it only the roles each integration needs.", + "Url": "https://hub.prowler.com/check/aiservices_account_managed_identity_enabled" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.py new file mode 100644 index 0000000000..22cc034bb7 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + +NO_IDENTITY_TYPE = "none" + + +class aiservices_account_managed_identity_enabled(Check): + """AI services account has a managed identity.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate the managed identity of every AI services account. + + Azure reports a removed identity either as no identity block or as + type `None`; both fail. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + identity_type = account.identity_type + if identity_type and identity_type.lower() != NO_IDENTITY_TYPE: + report.status = "PASS" + report.status_extended = ( + f"{prefix} has a managed identity ({identity_type})." + ) + else: + report.status = "FAIL" + report.status_extended = f"{prefix} has no managed identity." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.metadata.json new file mode 100644 index 0000000000..cb45ae9fc7 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_network_acls_default_action_deny", + "CheckTitle": "AI services account network access rules deny public traffic by default", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) that keep **public network access** enabled can set the network ACL default action (`networkAcls.defaultAction`) to `Deny`, so the public endpoint only accepts traffic from allowed virtual networks and IP ranges. Accounts with public network access `Disabled` also pass.", + "Risk": "With the default action set to `Allow`, the public endpoint accepts traffic from any network:\n- **Confidentiality**: prompts, completions, and grounding data are reachable by anyone holding a leaked key or token\n- **Integrity**: unauthorized model calls and deployment changes from any source\n- **Availability**: quota exhaustion and cost abuse from scanning and automated traffic", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-virtual-networks", + "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/cognitive-services-security-baseline" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.networkAcls.defaultAction=Deny", + "NativeIaC": "```bicep\n// Deny public traffic by default on an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n networkAcls: {\n defaultAction: 'Deny' // CRITICAL: only allowed networks reach the public endpoint\n ipRules: [\n { value: '' }\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open the Azure AI services or Azure OpenAI resource\n2. Go to Resource Management > Networking\n3. Under Firewalls and virtual networks, select Selected Networks and Private Endpoints\n4. Add the required virtual networks and IP ranges\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n network_acls {\n default_action = \"Deny\" # CRITICAL: only allowed networks reach the public endpoint\n ip_rules = [\"\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Prefer disabling **public network access** and using **Private Link**. Where the public endpoint must stay on, set the network ACL default action to `Deny` and allow only the virtual networks and IP ranges each workload needs.", + "Url": "https://hub.prowler.com/check/aiservices_account_network_acls_default_action_deny" + } + }, + "Categories": [ + "internet-exposed", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "aiservices_account_public_network_access_disabled" + ], + "Notes": "Mirrors the Azure Policy built-in \"Azure AI Services resources should restrict network access\", which flags accounts where public network access is not Disabled and the network ACL default action is not Deny. Add the needed IP ranges and virtual networks before switching to Deny to avoid losing connectivity." +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.py b/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.py new file mode 100644 index 0000000000..6bf247b675 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_network_acls_default_action_deny(Check): + """AI services account denies public network access by default.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate the network ACL default action on every AI services account. + + PASS when public network access is disabled, or when the network ACL + default action is `Deny` so only selected networks reach the endpoint. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if not account.public_network_access: + report.status = "PASS" + report.status_extended = ( + f"{prefix} has public network access disabled." + ) + elif account.network_acls_default_action.lower() == "deny": + report.status = "PASS" + report.status_extended = f"{prefix} restricts public network access to selected networks." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} allows public network access from all networks." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.metadata.json new file mode 100644 index 0000000000..08117f1b7a --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_outbound_network_restricted", + "CheckTitle": "AI services account restricts outbound network access to an allowed FQDN list", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) can call external endpoints, for example to fetch data for features that reach out to URLs. Setting `restrictOutboundNetworkAccess` to `true` limits those calls to the domains in `allowedFqdnList`.", + "Risk": "Unrestricted outbound access lets the service send data to any endpoint:\n- **Exfiltration**: a prompt-injected or misconfigured feature can post data to an attacker-controlled host\n- **Trust boundary**: the account can reach destinations no one reviewed\n- **Compliance**: data-loss-prevention controls for AI workloads are not enforced", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-data-loss-prevention" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.restrictOutboundNetworkAccess=true properties.allowedFqdnList='[\"\"]'", + "NativeIaC": "```bicep\n// Restrict outbound calls from an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n restrictOutboundNetworkAccess: true // CRITICAL: blocks outbound calls outside the allow list\n allowedFqdnList: [\n ''\n ]\n }\n}\n```", + "Other": "1. List the external domains the account must reach, for example storage endpoints used by your data sources\n2. Deploy the Bicep or Terraform example with those domains in the allowed FQDN list\n3. Or run the Azure CLI command above with your domains in allowedFqdnList\n4. Test the features that call external endpoints; an empty allow list blocks every outbound call", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n outbound_network_access_restricted = true # CRITICAL: blocks outbound calls outside the allow list\n fqdns = [\"\"]\n}\n```" + }, + "Recommendation": { + "Text": "Enable **outbound network restriction** on AI services accounts and keep the **allowed FQDN list** to the domains each workload needs. Review the list when data sources change.", + "Url": "https://hub.prowler.com/check/aiservices_account_outbound_network_restricted" + } + }, + "Categories": [ + "trust-boundaries", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.py b/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.py new file mode 100644 index 0000000000..481ffa9843 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_outbound_network_restricted(Check): + """AI services account restricts outbound network access.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate outbound network restriction on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if account.restrict_outbound_network_access: + report.status = "PASS" + report.status_extended = f"{prefix} restricts outbound network access to an allowed FQDN list." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} allows unrestricted outbound network access." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..dee9d17d2b --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_public_network_access_disabled", + "CheckTitle": "AI services account has public network access disabled", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) can block their data-plane endpoint from the public internet by setting **public network access** to `Disabled`, so clients connect only through **private endpoints**.", + "Risk": "An internet-reachable AI endpoint exposes:\n- **Confidentiality**: prompts, completions, and grounding data to anyone holding a leaked key or token\n- **Integrity**: unauthorized model calls and deployment changes\n- **Availability**: quota exhaustion and cost abuse from scanning and automated traffic", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-virtual-networks", + "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/cognitive-services-security-baseline" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.publicNetworkAccess=Disabled", + "NativeIaC": "```bicep\n// Disable public network access for an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n publicNetworkAccess: 'Disabled' // CRITICAL: blocks access from the public internet\n }\n}\n```", + "Other": "1. In the Azure portal, open the Azure AI services or Azure OpenAI resource\n2. Go to Resource Management > Networking\n3. Under Firewalls and virtual networks, select Disabled\n4. Click Save", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n public_network_access_enabled = false # CRITICAL: blocks access from the public internet\n}\n```" + }, + "Recommendation": { + "Text": "Set **public network access** to `Disabled` and reach the account through **Private Link**. If public access is temporarily required, restrict it to specific IP ranges or virtual networks and require Microsoft Entra ID authentication.", + "Url": "https://hub.prowler.com/check/aiservices_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py new file mode 100644 index 0000000000..b7268f1342 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_public_network_access_disabled(Check): + """AI services account has public network access disabled.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate public network access on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if account.public_network_access: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} has public network access enabled." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"{prefix} has public network access disabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.metadata.json new file mode 100644 index 0000000000..70f7967150 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_uses_private_endpoint", + "CheckTitle": "AI services account has an approved private endpoint connection", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) can be reached through a **private endpoint**, a private IP address in your virtual network. Only connections in the `Approved` state carry traffic; `Pending` and `Rejected` connections do not.", + "Risk": "Without an approved private endpoint, clients reach the account over its public endpoint:\n- **Confidentiality**: prompts and completions cross the public internet\n- **Exposure**: the endpoint stays reachable for credential stuffing and key replay\n- **Control**: network-layer controls such as NSGs and private DNS do not apply", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-virtual-networks", + "https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview" + ], + "Remediation": { + "Code": { + "CLI": "az network private-endpoint create --name --resource-group --vnet-name --subnet --private-connection-resource-id --group-id account --connection-name ", + "NativeIaC": "```bicep\n// Private endpoint for an Azure AI services account\nresource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-11-01' = {\n name: ''\n location: ''\n properties: {\n subnet: { id: '' }\n privateLinkServiceConnections: [\n {\n name: ''\n properties: {\n privateLinkServiceId: ''\n groupIds: [ 'account' ] // CRITICAL: connects the account through the private endpoint\n }\n }\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, open the AI services account and go to Resource Management > Networking\n2. Select the Private endpoint connections tab and click + Private endpoint\n3. Choose the virtual network and subnet, keep the target sub-resource account, and enable private DNS integration\n4. Approve the connection if it shows Pending, then set Public network access to Disabled", + "Terraform": "```hcl\nresource \"azurerm_private_endpoint\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n subnet_id = azurerm_subnet..id\n\n private_service_connection {\n name = \"\"\n private_connection_resource_id = azurerm_cognitive_account..id\n subresource_names = [\"account\"] # CRITICAL: connects the account through the private endpoint\n is_manual_connection = false\n }\n}\n```" + }, + "Recommendation": { + "Text": "Connect AI services accounts to your virtual network through an **approved private endpoint** with **private DNS** integration. Then disable **public network access** so the public endpoint stops accepting traffic.", + "Url": "https://hub.prowler.com/check/aiservices_account_uses_private_endpoint" + } + }, + "Categories": [ + "internet-exposed", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.py b/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.py new file mode 100644 index 0000000000..cde9cac618 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + +APPROVED_STATUS = "approved" + + +class aiservices_account_uses_private_endpoint(Check): + """AI services account has an approved private endpoint connection.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate private endpoint connections on every AI services account. + + Pending and rejected connections carry no traffic, so only an + approved connection passes. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if any( + status.lower() == APPROVED_STATUS + for status in account.private_endpoint_connection_statuses + ): + report.status = "PASS" + report.status_extended = ( + f"{prefix} has an approved private endpoint connection." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} has no approved private endpoint connection." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_client.py b/prowler/providers/azure/services/aiservices/aiservices_client.py new file mode 100644 index 0000000000..af0e34e68f --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.aiservices.aiservices_service import AIServices +from prowler.providers.common.provider import Provider + +aiservices_client = AIServices(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.metadata.json new file mode 100644 index 0000000000..21cb36e2b2 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_deployment_content_filter_prompt_shield_enabled", + "CheckTitle": "AI services model deployment blocks jailbreak attempts with Prompt Shields", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.cognitiveservices/accounts/deployments", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services model deployments** (including **Azure OpenAI** and **Azure AI Foundry**) apply a **content filter policy** (`raiPolicyName`). **Prompt Shields** for direct attacks is the policy's `Jailbreak` filter; it should be enabled and blocking on prompts so user prompt injection attempts are rejected before the model runs.", + "Risk": "Without a blocking jailbreak filter, crafted prompts can override the system prompt and safety training:\n- **Integrity**: the model ignores its instructions and produces harmful or policy-violating output\n- **Confidentiality**: attackers extract system prompts, grounding data, or tool results\n- **Excessive agency**: agents with tools act on attacker instructions", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/content-filter-prompt-shields", + "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/content-filters", + "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/default-safety-policies" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.raiPolicyName=Microsoft.DefaultV2", + "NativeIaC": "```bicep\n// Deploy a model with a content filter policy that blocks jailbreak attempts\nresource deployment 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = {\n parent: account\n name: ''\n sku: { name: 'Standard', capacity: }\n properties: {\n model: { format: 'OpenAI', name: '', version: '' }\n raiPolicyName: 'Microsoft.DefaultV2' // CRITICAL: policy includes a blocking Jailbreak filter on prompts\n }\n}\n```", + "Other": "1. In the Azure AI Foundry portal, open the resource and go to Guardrails + controls > Content filters\n2. Create or edit a content filter and, under Input filter, turn on Prompt Shields for jailbreak attacks with the Annotate and block action\n3. Go to Deployments, open the deployment, and assign that content filter\n4. Save the deployment", + "Terraform": "```hcl\nresource \"azurerm_cognitive_deployment\" \"\" {\n name = \"\"\n cognitive_account_id = \"\"\n rai_policy_name = \"Microsoft.DefaultV2\" # CRITICAL: policy includes a blocking Jailbreak filter on prompts\n\n model {\n format = \"OpenAI\"\n name = \"\"\n version = \"\"\n }\n\n sku {\n name = \"Standard\"\n capacity = \n }\n}\n```" + }, + "Recommendation": { + "Text": "Assign every model deployment an explicit content filter policy whose **Prompt Shields** jailbreak filter is enabled and set to **block** on prompts, such as `Microsoft.DefaultV2` or a custom policy. Avoid relying on the account default, which cannot be verified from configuration.", + "Url": "https://hub.prowler.com/check/aiservices_deployment_content_filter_prompt_shield_enabled" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Deployments with an empty raiPolicyName use the account default content filter policy. Azure Resource Manager does not report which policy that is, and the legacy Microsoft.Default policy has no Jailbreak filter, so these deployments are reported as MANUAL. Deployments whose capabilities show no text generation (embeddings, transcription, image generation) are skipped. A custom policy must list its own Jailbreak filter; filters inherited from basePolicyName are not evaluated." +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.py b/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.py new file mode 100644 index 0000000000..0600753258 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled.py @@ -0,0 +1,117 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) +from prowler.providers.azure.services.aiservices.aiservices_service import ( + Deployment, + RaiPolicy, +) + +# Deployment capabilities that take text prompts, where Prompt Shields applies. +TEXT_GENERATION_CAPABILITIES = { + "chatcompletion", + "completion", + "responses", + "assistants", +} +# Capabilities of models that take no text prompts. +NON_TEXT_CAPABILITIES = {"embeddings", "imagegenerations"} + + +class aiservices_deployment_content_filter_prompt_shield_enabled(Check): + """AI services deployment blocks jailbreak attempts with Prompt Shields.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate the content filter policy of every model deployment. + + PASS when the deployment's policy has the `Jailbreak` filter enabled + and blocking on prompts. MANUAL when the deployment uses the account + default policy (empty `raiPolicyName`), because Azure does not say + which policy that is, or when the policy cannot be read. + + Deployments whose capabilities show only non-text models (embeddings, + image generation) are skipped. All other deployments are evaluated, + including those that report no or unrecognized capabilities. + + Returns: + One finding per evaluated deployment, plus one MANUAL finding per account + whose deployments cannot be read. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + subscription = f"subscription {subscription_name} ({subscription_id})" + for account in accounts.values(): + if account.deployments is None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=account + ) + report.subscription = subscription_id + report.status = "MANUAL" + report.status_extended = f"Deployments of AI services account {account.name} (kind {account.kind}) from {subscription} could not be read." + findings.append(report) + continue + # ARM resource names are case-insensitive. + rai_policies = ( + None + if account.rai_policies is None + else { + name.lower(): policy + for name, policy in account.rai_policies.items() + } + ) + for deployment in account.deployments: + if not _takes_text_prompts(deployment): + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=deployment + ) + report.subscription = subscription_id + prefix = f"AI services deployment {deployment.name} (model {deployment.model_name}) in account {account.name} from {subscription}" + policy_name = deployment.rai_policy_name + if not policy_name: + report.status = "MANUAL" + report.status_extended = f"{prefix} uses the account default content filter policy, which Azure does not identify. Confirm that it blocks jailbreak attempts with Prompt Shields." + elif rai_policies is None: + report.status = "MANUAL" + report.status_extended = f"{prefix} uses content filter policy {policy_name}, which could not be read." + elif policy_name.lower() not in rai_policies: + report.status = "MANUAL" + report.status_extended = f"{prefix} uses content filter policy {policy_name}, which was not found on the account." + elif _blocks_jailbreak(rai_policies[policy_name.lower()]): + report.status = "PASS" + report.status_extended = f"{prefix} uses content filter policy {policy_name}, which blocks jailbreak attempts with Prompt Shields." + else: + report.status = "FAIL" + report.status_extended = f"{prefix} uses content filter policy {policy_name}, which does not block jailbreak attempts with Prompt Shields." + findings.append(report) + return findings + + +def _takes_text_prompts(deployment: Deployment) -> bool: + """Return whether a deployment may serve a model that takes text prompts. + + Capabilities are an open map, so only a known non-text capability with no + text-generation capability rules a deployment out. + """ + enabled = { + name.lower() + for name, value in deployment.capabilities.items() + if str(value).lower() == "true" + } + return bool(enabled & TEXT_GENERATION_CAPABILITIES) or not ( + enabled & NON_TEXT_CAPABILITIES + ) + + +def _blocks_jailbreak(policy: RaiPolicy) -> bool: + """Return whether a policy blocks jailbreak attempts on prompts.""" + return any( + content_filter.name.lower() == "jailbreak" + and content_filter.source.lower() == "prompt" + and content_filter.enabled + and content_filter.blocking + for content_filter in policy.content_filters + ) diff --git a/prowler/providers/azure/services/aiservices/aiservices_service.py b/prowler/providers/azure/services/aiservices/aiservices_service.py new file mode 100644 index 0000000000..8317950a7d --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_service.py @@ -0,0 +1,321 @@ +from typing import Optional + +from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + +MICROSOFT_MANAGED_KEY_SOURCE = "Microsoft.CognitiveServices" + + +class AIServices(AzureService): + """Azure AI services (Microsoft.CognitiveServices/accounts). + + Covers every account kind, including Azure OpenAI (`OpenAI`) and + Azure AI Foundry (`AIServices`). + """ + + def __init__(self, provider: AzureProvider): + super().__init__(CognitiveServicesManagementClient, provider) + self.accounts = self._get_accounts() + + def _get_accounts(self) -> dict[str, dict[str, "Account"]]: + """Get Azure AI services accounts for every audited subscription. + + Returns: + Accounts keyed by subscription ID, then by account resource ID. + """ + logger.info("AIServices - Getting accounts...") + accounts = {} + for subscription, client in self.clients.items(): + accounts[subscription] = {} + try: + sdk_accounts = self.list_with_rg_scope( + subscription, + client.accounts.list, + client.accounts.list_by_resource_group, + ) + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + for sdk_account in sdk_accounts: + try: + account = self._to_account(sdk_account) + account.monitor_diagnostic_settings = self._get_diagnostic_settings( + subscription, account.id + ) + account.deployments = self._get_deployments( + subscription, client, account + ) + account.rai_policies = self._get_rai_policies( + subscription, client, account + ) + accounts[subscription][account.id] = account + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return accounts + + @staticmethod + def _to_account(sdk_account) -> "Account": + """Map an SDK account to the Prowler model. + + Unset properties follow Azure defaults: public network access on, + local (key) authentication on, Microsoft-managed encryption, no + managed identity, unrestricted outbound access, and a network ACL + default action of `Allow`. + + Args: + sdk_account: `azure.mgmt.cognitiveservices.models.Account`. + + Returns: + The mapped `Account`. + """ + properties = sdk_account.properties + encryption = getattr(properties, "encryption", None) + key_vault_properties = getattr(encryption, "key_vault_properties", None) + default_action = getattr( + getattr(properties, "network_acls", None), "default_action", None + ) + return Account( + id=sdk_account.id, + name=sdk_account.name, + location=sdk_account.location, + kind=sdk_account.kind or "", + public_network_access=( + getattr(properties, "public_network_access", None) != "Disabled" + ), + disable_local_auth=bool(getattr(properties, "disable_local_auth", False)), + encryption_key_source=( + getattr(encryption, "key_source", None) or MICROSOFT_MANAGED_KEY_SOURCE + ), + encryption_key_name=getattr(key_vault_properties, "key_name", None), + private_endpoint_connection_statuses=AIServices._private_endpoint_statuses( + properties + ), + identity_type=getattr(getattr(sdk_account, "identity", None), "type", None), + restrict_outbound_network_access=bool( + getattr(properties, "restrict_outbound_network_access", False) + ), + # NetworkRuleAction is a str enum; keep the plain value. + network_acls_default_action=( + getattr(default_action, "value", default_action) or "Allow" + ), + ) + + @staticmethod + def _get_deployments( + subscription: str, client, account: "Account" + ) -> Optional[list["Deployment"]]: + """Get the model deployments of one account. + + Args: + subscription: Subscription ID that holds the account. + client: `CognitiveServicesManagementClient` for the subscription. + account: The account whose deployments are listed. + + Returns: + The account's deployments, or `None` when they cannot be read. + """ + try: + deployments = [] + for sdk_deployment in client.deployments.list( + resource_group_name=_resource_group(account.id), + account_name=account.name, + ): + properties = getattr(sdk_deployment, "properties", None) + capabilities = getattr(properties, "capabilities", None) + deployments.append( + Deployment( + id=sdk_deployment.id, + name=sdk_deployment.name, + location=account.location, + model_name=getattr( + getattr(properties, "model", None), "name", None + ) + or "", + # An empty name means the deployment uses the account + # default policy, which ARM does not identify. + rai_policy_name=getattr(properties, "rai_policy_name", None) + or "", + capabilities=( + capabilities if isinstance(capabilities, dict) else {} + ), + ) + ) + return deployments + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + @staticmethod + def _get_rai_policies( + subscription: str, client, account: "Account" + ) -> Optional[dict[str, "RaiPolicy"]]: + """Get the content filter (RAI) policies of one account. + + Uses `list` because `get` returns 404 for system-managed policies such + as `Microsoft.Default`. + + Args: + subscription: Subscription ID that holds the account. + client: `CognitiveServicesManagementClient` for the subscription. + account: The account whose policies are listed. + + Returns: + Policies keyed by name, or `None` when they cannot be read. + """ + try: + policies = {} + for sdk_policy in client.rai_policies.list( + resource_group_name=_resource_group(account.id), + account_name=account.name, + ): + content_filters = [] + for sdk_filter in ( + getattr( + getattr(sdk_policy, "properties", None), + "content_filters", + None, + ) + or [] + ): + source = getattr(sdk_filter, "source", None) + content_filters.append( + ContentFilter( + name=getattr(sdk_filter, "name", None) or "", + # RaiPolicyContentSource is a str enum; keep the plain value. + source=getattr(source, "value", source) or "", + enabled=bool(getattr(sdk_filter, "enabled", False)), + blocking=bool(getattr(sdk_filter, "blocking", False)), + ) + ) + policies[sdk_policy.name] = RaiPolicy( + name=sdk_policy.name, content_filters=content_filters + ) + return policies + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + @staticmethod + def _private_endpoint_statuses(properties) -> list[str]: + """Get the connection status of each private endpoint on an account. + + Args: + properties: `azure.mgmt.cognitiveservices.models.AccountProperties`, + or `None`. + + Returns: + One status (`Approved`, `Pending`, `Rejected`) per connection that + reports one. + """ + statuses = [] + for connection in ( + getattr(properties, "private_endpoint_connections", None) or [] + ): + state = getattr( + getattr(connection, "properties", None), + "private_link_service_connection_state", + None, + ) + status = getattr(state, "status", None) + if status: + statuses.append(status) + return statuses + + @staticmethod + def _get_diagnostic_settings(subscription: str, account_id: str) -> Optional[list]: + """Get the Azure Monitor diagnostic settings of one account. + + Args: + subscription: Subscription ID that holds the account. + account_id: Account resource ID. + + Returns: + The account's `DiagnosticSetting` items, or `None` when they cannot + be read. + """ + try: + # Imported here because the Monitor client is built from the global + # provider at import time; importing this module must not need it. + from prowler.providers.azure.services.monitor.monitor_client import ( + monitor_client, + ) + + return monitor_client.diagnostic_settings_with_uri( + subscription, + account_id, + monitor_client.clients[subscription], + raise_errors=True, + ) + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + +def _resource_group(resource_id: str) -> str: + """Get the resource group name from an ARM resource ID.""" + return resource_id.split("/")[4] + + +class ContentFilter(BaseModel): + """One content filter of a content filter (RAI) policy.""" + + name: str + source: str + enabled: bool + blocking: bool + + +class RaiPolicy(BaseModel): + """Content filter (RAI) policy of an AI services account.""" + + name: str + content_filters: list[ContentFilter] = [] + + +class Deployment(BaseModel): + """Model deployment of an AI services account.""" + + id: str + name: str + location: str + model_name: str = "" + rai_policy_name: str = "" + # Model capabilities, for example {"chatCompletion": "true"}. + capabilities: dict[str, str] = {} + + +class Account(BaseModel): + """Azure AI services account.""" + + id: str + name: str + location: str + kind: str + public_network_access: bool + disable_local_auth: bool + encryption_key_source: str + encryption_key_name: Optional[str] = None + private_endpoint_connection_statuses: list[str] = [] + identity_type: Optional[str] = None + restrict_outbound_network_access: bool = False + network_acls_default_action: str = "Allow" + # None when the list call fails, so checks can report MANUAL. + deployments: Optional[list[Deployment]] = None + rai_policies: Optional[dict[str, RaiPolicy]] = None + # Monitor DiagnosticSetting dataclasses. Left untyped: Pydantic would + # re-validate them and reject log entries whose category is None. + monitor_diagnostic_settings: Optional[list] = None diff --git a/prowler/providers/azure/services/monitor/monitor_service.py b/prowler/providers/azure/services/monitor/monitor_service.py index 07d41d58f2..1bfa63c76f 100644 --- a/prowler/providers/azure/services/monitor/monitor_service.py +++ b/prowler/providers/azure/services/monitor/monitor_service.py @@ -33,7 +33,23 @@ class Monitor(AzureService): ) return diagnostics_settings - def diagnostic_settings_with_uri(self, subscription, uri, client): + def diagnostic_settings_with_uri( + self, subscription, uri, client, raise_errors: bool = False + ): + """Get the diagnostic settings of one resource. + + Args: + subscription: Subscription ID, used for logging. + uri: Resource URI to read the diagnostic settings from. + client: `MonitorManagementClient` of the subscription. + raise_errors: When `True`, API errors are re-raised unlogged so the + caller can handle and log them and tell a failure from "no + settings configured". + + Returns: + The resource's `DiagnosticSetting` items. On error with + `raise_errors=False`, the items collected so far (usually `[]`). + """ diagnostics_settings = [] try: settings = client.diagnostic_settings.list(resource_uri=uri) @@ -60,6 +76,8 @@ class Monitor(AzureService): ) ) except Exception as error: + if raise_errors: + raise logger.error( f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/gcp/services/vertexai/__init__.py b/prowler/providers/gcp/services/vertexai/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/vertexai/vertexai_client.py b/prowler/providers/gcp/services/vertexai/vertexai_client.py new file mode 100644 index 0000000000..179aa4ebd1 --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.vertexai.vertexai_service import VertexAI + +vertexai_client = VertexAI(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/vertexai/vertexai_service.py b/prowler/providers/gcp/services/vertexai/vertexai_service.py new file mode 100644 index 0000000000..9cb3e5ef2d --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_service.py @@ -0,0 +1,89 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + +# Workbench metadata key that controls sudo access for the notebook user. +ROOT_ACCESS_DISABLED_METADATA_KEY = "notebook-disable-root" + + +class VertexAI(GCPService): + """Vertex AI service client. + + Enumerates Vertex AI Workbench instances across every accessible project + using the `notebooks.googleapis.com` v2 API and exposes them through the + `workbench_instances` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Workbench instances.""" + super().__init__("notebooks", provider, api_version="v2") + self.workbench_instances = [] + self._get_workbench_instances() + + def _get_workbench_instances(self) -> None: + """Fetch Workbench instances for every project across all locations.""" + for project_id in self.project_ids: + try: + instances = self.client.projects().locations().instances() + request = instances.list(parent=f"projects/{project_id}/locations/-") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for instance in response.get("instances", []): + gce_setup = instance.get("gceSetup", {}) + metadata = gce_setup.get("metadata", {}) + network_interfaces = gce_setup.get("networkInterfaces", []) + # Name format: projects/{project}/locations/{zone}/instances/{instance} + name_parts = instance["name"].split("/") + self.workbench_instances.append( + WorkbenchInstance( + id=instance["name"], + name=name_parts[-1], + project_id=project_id, + location=name_parts[3], + state=instance.get("state", "STATE_UNSPECIFIED"), + public_ip_disabled=gce_setup.get( + "disablePublicIp", False + ), + root_access_disabled=str( + metadata.get(ROOT_ACCESS_DISABLED_METADATA_KEY, "") + ).lower() + == "true", + network=( + network_interfaces[0].get("network") + if network_interfaces + else None + ), + ) + ) + # The wildcard list reports locations it could not reach + # instead of raising, so the project was not fully scanned. + unreachable = response.get("unreachable", []) + if unreachable: + logger.error( + f"{project_id} -- Workbench instances could not be listed in unreachable locations: {', '.join(unreachable)}" + ) + request = instances.list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class WorkbenchInstance(BaseModel): + """Vertex AI Workbench instance resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str + state: str + public_ip_disabled: bool = False + root_access_disabled: bool = False + network: Optional[str] = None diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/__init__.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json new file mode 100644 index 0000000000..9d79a54613 --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "vertexai_workbench_instance_public_ip_disabled", + "CheckTitle": "Vertex AI Workbench instance does not have a public IP address", + "CheckType": [], + "ServiceName": "vertexai", + "SubServiceName": "workbench", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "notebooks.googleapis.com/Instance", + "ResourceGroup": "ai_ml", + "Description": "**Vertex AI Workbench instances** are JupyterLab VMs used to explore data, train models, and call generative AI APIs. The finding determines whether an instance was created with **public IP disabled** (`disablePublicIp`), so it has no external address and reaches the internet only through controlled egress such as **Cloud NAT** or **Private Google Access**.", + "Risk": "A notebook VM with a public IP has **direct internet access**:\n- **Confidentiality**: training data, models, and service account tokens can be exfiltrated around VPC egress controls\n- **Integrity**: untrusted packages and models are pulled straight into an environment holding project credentials\n- **Exposure**: the Jupyter stack is reachable for scanning and exploitation", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/vertex-ai/docs/workbench/instances/create", + "https://cloud.google.com/vertex-ai/docs/workbench/instances/introduction" + ], + "Remediation": { + "Code": { + "CLI": "curl -X PATCH -H \"Authorization: Bearer $(gcloud auth print-access-token)\" -H \"Content-Type: application/json\" -d '{\"gceSetup\": {\"disablePublicIp\": true}}' \"https://notebooks.googleapis.com/v2/projects//locations//instances/?updateMask=gceSetup.disable_public_ip\"", + "NativeIaC": "", + "Other": "Update the existing instance in place; `gcloud workbench instances update` has no public IP flag, so use the Notebooks API.\n1. Confirm the instance subnetwork has Private Google Access or Cloud NAT, so the notebook keeps access to Google APIs and package repositories\n2. Call `instances.patch` with `updateMask=gceSetup.disable_public_ip` and `gceSetup.disablePublicIp` set to `true`, as in the CLI command\n3. Wait for the returned operation to finish, then confirm in Compute Engine > VM instances that the instance VM has no external IP\n4. Only if the update is rejected, create a new instance with `gcloud workbench instances create --disable-public-ip`, migrate notebooks and data, and delete the old instance", + "Terraform": "```hcl\nresource \"google_workbench_instance\" \"\" {\n name = \"\"\n location = \"\"\n\n gce_setup {\n disable_public_ip = true # FIX: No external IP is assigned to the notebook VM\n\n network_interfaces {\n network = \"\"\n subnet = \"\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create Workbench instances **without external IPs** and keep them on private networks.\n- Use **Cloud NAT** for controlled egress and **Private Google Access** for Google APIs\n- Reach JupyterLab through the IAM-authenticated **Workbench proxy** or **IAP**\n- Place AI projects inside a **VPC Service Controls** perimeter\n- Enforce the `constraints/compute.vmExternalIpAccess` organization policy", + "Url": "https://hub.prowler.com/check/vertexai_workbench_instance_public_ip_disabled" + } + }, + "Categories": [ + "internet-exposed", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "compute_instance_public_ip" + ], + "Notes": "Workbench instances are backed by Compute Engine VMs, which compute_instance_public_ip also evaluates; this check reports the finding against the Workbench resource that owners manage." +} diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.py new file mode 100644 index 0000000000..31a69502b4 --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.vertexai.vertexai_client import vertexai_client + + +class vertexai_workbench_instance_public_ip_disabled(Check): + """Ensure Vertex AI Workbench instances do not have a public IP address. + + A Workbench instance with an external IP is directly reachable from, and can + reach, the internet without passing through VPC egress controls. + - PASS: The instance was created with `disablePublicIp` set. + - FAIL: The instance has a public IP address assigned. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public IP check across all Workbench instances. + + Returns: + A list of `Check_Report_GCP` findings, one per non-deleted + Workbench instance. + """ + findings = [] + for instance in vertexai_client.workbench_instances: + if instance.state == "DELETED": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.public_ip_disabled: + report.status = "PASS" + report.status_extended = f"Vertex AI Workbench instance {instance.name} does not have a public IP address." + else: + report.status = "FAIL" + report.status_extended = f"Vertex AI Workbench instance {instance.name} has a public IP address assigned, allowing direct internet access." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/__init__.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json new file mode 100644 index 0000000000..5cf2305989 --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "vertexai_workbench_instance_root_access_disabled", + "CheckTitle": "Vertex AI Workbench instance has root access disabled", + "CheckType": [], + "ServiceName": "vertexai", + "SubServiceName": "workbench", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "notebooks.googleapis.com/Instance", + "ResourceGroup": "ai_ml", + "Description": "**Vertex AI Workbench instances** give the JupyterLab user **passwordless sudo** by default. The finding determines whether root access is disabled by setting the `notebook-disable-root` instance metadata key to `true`.", + "Risk": "With root access, a notebook user, or any malicious package, model, or notebook they run, can:\n- **Integrity**: tamper with the OS, Jupyter server, and libraries, or install persistent backdoors\n- **Confidentiality**: read every user's files and credentials on the VM\n- **Defense evasion**: disable monitoring agents and audit tooling", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/vertex-ai/docs/workbench/instances/manage-metadata", + "https://cloud.google.com/vertex-ai/docs/workbench/instances/introduction" + ], + "Remediation": { + "Code": { + "CLI": "gcloud workbench instances update --location= --metadata=notebook-disable-root=true", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Vertex AI > Workbench > Instances\n2. Click the instance name and open the Software and security tab\n3. Under Metadata, add the key notebook-disable-root with the value true\n4. Click Submit and restart the instance if prompted", + "Terraform": "```hcl\nresource \"google_workbench_instance\" \"\" {\n name = \"\"\n location = \"\"\n\n gce_setup {\n metadata = {\n notebook-disable-root = \"true\" # FIX: Removes passwordless sudo for the notebook user\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable root access on Workbench instances and apply **least privilege** to notebook users.\n- Install required packages in user space (`pip install --user`, virtual environments, or custom containers)\n- Build approved images for system-level dependencies instead of granting sudo\n- Attach a dedicated, minimally scoped **service account** to each instance", + "Url": "https://hub.prowler.com/check/vertexai_workbench_instance_root_access_disabled" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.py new file mode 100644 index 0000000000..85617fb777 --- /dev/null +++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.vertexai.vertexai_client import vertexai_client + + +class vertexai_workbench_instance_root_access_disabled(Check): + """Ensure Vertex AI Workbench instances have root access disabled. + + Workbench grants the notebook user passwordless sudo unless the + `notebook-disable-root` metadata key is set to `true`. + - PASS: The instance has `notebook-disable-root` set to `true`. + - FAIL: The notebook user can obtain root on the instance. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the root access check across all Workbench instances. + + Returns: + A list of `Check_Report_GCP` findings, one per non-deleted + Workbench instance. + """ + findings = [] + for instance in vertexai_client.workbench_instances: + if instance.state == "DELETED": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.root_access_disabled: + report.status = "PASS" + report.status_extended = f"Vertex AI Workbench instance {instance.name} has root access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Vertex AI Workbench instance {instance.name} has root access enabled." + findings.append(report) + return findings diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index f9477bc5ce..96de189725 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -17,6 +17,10 @@ from prowler.config.config import ( ) from prowler.lib.check.models import CheckReportIAC from prowler.lib.logger import logger +from prowler.lib.network.ssrf import ( + OutboundURLNotAllowedError, + validate_outbound_url, +) from prowler.lib.utils.utils import print_boxes from prowler.lib.utils.vulnerability_references import ( build_finding_reference_url, @@ -31,6 +35,14 @@ from prowler.providers.iac.exceptions.exceptions import ( IacScanError, IacTrivyNotFoundError, ) +from prowler.providers.iac.lib.git_transport import ( + ALLOWED_SCHEMES, + effective_url, + git_config, + guarded_pool_manager, + ls_remote, + proxy_base_url, +) class IacProvider(Provider): @@ -326,6 +338,11 @@ class IacProvider(Provider): Returns: tuple[str, str]: (temporary_directory, branch_name) """ + # one config for both: git rewrites the URL again inside porcelain, and the + # guard must judge the same destination dulwich will connect to + config = git_config() + target = effective_url(config, repository_url) + validate_outbound_url(target, allowed_schemes=ALLOWED_SCHEMES) try: original_url = repository_url @@ -341,6 +358,7 @@ class IacProvider(Provider): ) temporary_directory = tempfile.mkdtemp() + pool_manager = guarded_pool_manager(config, proxy_base_url(target)) logger.info( f"Cloning repository {original_url} into {temporary_directory}..." ) @@ -360,7 +378,11 @@ class IacProvider(Provider): try: bar.title = f"-> Cloning {original_url}..." porcelain.clone( - repository_url, temporary_directory, depth=1 + repository_url, + temporary_directory, + depth=1, + config=config, + pool_manager=pool_manager, ) bar.title = "-> Repository cloned successfully!" except Exception as clone_error: @@ -369,12 +391,24 @@ class IacProvider(Provider): else: # No TTY, just clone without progress bar logger.info(f"Cloning {original_url}...") - porcelain.clone(repository_url, temporary_directory, depth=1) + porcelain.clone( + repository_url, + temporary_directory, + depth=1, + config=config, + pool_manager=pool_manager, + ) logger.info("Repository cloned successfully!") except (AttributeError, OSError): # Fallback if isatty() check fails logger.info(f"Cloning {original_url}...") - porcelain.clone(repository_url, temporary_directory, depth=1) + porcelain.clone( + repository_url, + temporary_directory, + depth=1, + config=config, + pool_manager=pool_manager, + ) logger.info("Repository cloned successfully!") # Detect the branch name from the cloned repository @@ -382,11 +416,14 @@ class IacProvider(Provider): return temporary_directory, branch_name except Exception as error: + # the authenticated URL embeds the token, and ProwlerException puts + # original_exception into its str(), which the API returns verbatim logger.critical( - f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + f"{error.__class__.__name__}:{error.__traceback__.tb_lineno}" ) raise IacRepositoryCloneError( - file=__file__, original_exception=error + file=__file__, + message=f"Unable to clone the repository to scan ({error.__class__.__name__})", ) from error def run(self) -> List[CheckReportIAC]: @@ -652,6 +689,17 @@ class IacProvider(Provider): is_connected=False, error="Repository URL is required" ) + try: + validate_outbound_url( + scan_repository_url, allowed_schemes=ALLOWED_SCHEMES + ) + except OutboundURLNotAllowedError as error: + logger.warning(f"Rejected IaC repository URL: {error}") + return Connection( + is_connected=False, + error="Repository URL is not an allowed destination.", + ) + # Try to clone the repository to test the connection with tempfile.TemporaryDirectory(): try: @@ -670,13 +718,19 @@ class IacProvider(Provider): # Public repository auth_url = scan_repository_url - # Use dulwich to test the connection - porcelain.ls_remote(auth_url) + # not porcelain.ls_remote: it cannot be given the pool manager + # that validates redirect destinations + ls_remote(auth_url) return Connection(is_connected=True) except Exception as e: + # A hostile remote can echo the Authorization header back in a git + # ERR packet that dulwich copies into the exception text error_msg = str(e) + logger.error( + f"IaC repository connection test failed: {e.__class__.__name__}" + ) if "authentication" in error_msg.lower() or "401" in error_msg: return Connection( is_connected=False, @@ -690,13 +744,14 @@ class IacProvider(Provider): else: return Connection( is_connected=False, - error=f"Failed to connect to repository: {error_msg}", + error="Failed to connect to repository.", ) except Exception as error: if raise_on_exception: raise + logger.error(f"Unexpected error testing IaC repository connection: {error}") return Connection( is_connected=False, - error=f"Unexpected error testing connection: {str(error)}", + error="Unexpected error testing connection.", ) diff --git a/prowler/providers/iac/lib/git_transport.py b/prowler/providers/iac/lib/git_transport.py new file mode 100644 index 0000000000..ef0ca09d9c --- /dev/null +++ b/prowler/providers/iac/lib/git_transport.py @@ -0,0 +1,98 @@ +"""dulwich HTTP transport that keeps redirect destinations inside the SSRF guard.""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse, urlunparse + +import urllib3 +from dulwich.client import default_urllib3_manager, get_transport_and_path +from dulwich.config import Config, StackedConfig, apply_instead_of, env_config + +from prowler.lib.network.ssrf import validate_outbound_url + +# shared so the connection test and the clone cannot disagree about what a +# tenant is allowed to configure +ALLOWED_SCHEMES = ("http", "https", "ssh", "git+ssh", "git") + + +class _GuardedRedirects: + def urlopen(self, method, url, redirect=True, **kwargs): + # urllib3 recurses through self.urlopen once per hop, so validating here + # covers the whole chain; dulwich adopts a hop as the new repository base + validate_outbound_url(url) + return super().urlopen(method, url, redirect=redirect, **kwargs) + + +class GuardedPoolManager(_GuardedRedirects, urllib3.PoolManager): + """Pool manager that validates every destination it is asked to open.""" + + +class GuardedProxyManager(_GuardedRedirects, urllib3.ProxyManager): + """Proxy manager that validates every destination it is asked to open.""" + + +def git_config() -> Config: + """Stacked git configuration, with the GIT_* overrides porcelain applies.""" + config = StackedConfig.default() + override = env_config(os.environ) + if override is not None: + config.backends.insert(0, override) + return config + + +def effective_url(config: Config, url: str) -> str: + """URL after git's ``url.*.insteadOf`` rewriting, which is what picks the transport. + + A rewrite can turn a validated public URL into an ``ssh://`` one on a private + host, so the guard has to see this form and not the supplied one. + """ + return apply_instead_of(config, url, push=False) + + +def proxy_base_url(url: str) -> str: + """URL without credentials, for dulwich's ``no_proxy`` and ``http..*`` matching. + + Only the userinfo is removed. The path stays, because an ``http..*`` + section can be path-specific and dulwich ranks matches by path length. + """ + parsed = urlparse(url) + if parsed.username is None and parsed.password is None: + return url + host = parsed.hostname or "" + # urlparse drops the brackets of an IPv6 literal and they are not optional + netloc = f"[{host}]" if ":" in host else host + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + return urlunparse(parsed._replace(netloc=netloc)) + + +def guarded_pool_manager(config: Config, base_url: str) -> urllib3.PoolManager: + """dulwich HTTP manager whose every request, redirects included, is validated. + + ``base_url`` is what dulwich uses to honour ``no_proxy`` and the per-URL + ``http.*`` settings, so building the manager without it silently drops them. + """ + return default_urllib3_manager( + config, + base_url=base_url, + pool_manager_cls=GuardedPoolManager, + proxy_manager_cls=GuardedProxyManager, + ) + + +def ls_remote(url: str): + """porcelain.ls_remote with the guarded manager, which porcelain cannot be given. + + The supplied URL is handed to dulwich, not the rewritten one: ``insteadOf`` is a + single substitution, so rewriting twice can land on a third destination. + """ + config = git_config() + target = effective_url(config, url) + validate_outbound_url(target, allowed_schemes=ALLOWED_SCHEMES) + client, path = get_transport_and_path( + url, + config=config, + pool_manager=guarded_pool_manager(config, proxy_base_url(target)), + ) + return client.get_refs(path.encode() if isinstance(path, str) else path) diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json new file mode 100644 index 0000000000..d75131d6b5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_app_registration_redirect_uris_secure", + "CheckTitle": "Secure redirect URIs protect application registration tokens from interception and theft", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Entra ID **application registrations** must use **secure redirect URIs**. This check flags web, SPA and public-client redirect URIs that use plain `http://` on non-loopback hosts, reside on shared platform domains like `azurewebsites.net`, contain wildcards, or are malformed.\n\nTokens are delivered to redirect URIs, so insecure URIs expose them to interception or theft.", + "Risk": "An insecure redirect URI allows an attacker to **intercept authorization codes and tokens** in transit (plain HTTP), **redirect them** to a host they control (wildcard or malformed URI), or **claim a dangling subdomain** on a shared platform domain like `azurewebsites.net` to receive valid tokens for the application's users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity-platform/reply-url", + "https://learn.microsoft.com/azure/security/fundamentals/subdomain-takeover", + "https://learn.microsoft.com/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app update --id \"$APP_ID\" --web-redirect-uris \"https://$OWNED_DOMAIN/signin-oidc\"", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Applications > App registrations\n3. Select the flagged application\n4. Go to Authentication\n5. Replace any `http://` redirect URIs with `https://`\n6. Remove any wildcard URIs and unused or malformed URIs\n7. Move applications off `*.azurewebsites.net` onto a custom domain you own\n8. Delete redirect URIs whose domain no longer resolves in DNS", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use only `https://` redirect URIs pointing to domains you own and control. Remove **wildcard** entries, replace shared platform domains with **custom domains**, and clean up unused or malformed URIs to prevent token theft via redirect manipulation or subdomain takeover.", + "Url": "https://hub.prowler.com/check/entra_app_registration_redirect_uris_secure" + } + }, + "Categories": [ + "internet-exposed", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DNS resolution checks for dangling domains are not performed by this check due to network egress requirements, non-deterministic results, and false positives on internal-only hostnames. Custom URI schemes used by public clients (msal://, ms-appx-web://, brk-://, urn:ietf:wg:oauth:2.0:oob, com.company.app://) are excluded from validation. Loopback hosts (localhost, 127.0.0.1, [::1]) are allowed over plain HTTP per Microsoft's platform behaviour." +} diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py new file mode 100644 index 0000000000..c86b6c9b0a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py @@ -0,0 +1,165 @@ +"""Check that application registration redirect URIs are secure.""" + +from urllib.parse import urlparse + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Custom URI schemes used by public clients that are valid and must not be +# flagged as malformed or checked for DNS resolution. +_PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES = ( + "msal", + "ms-appx-web", + "brk-", + "com.", + "msauth.", +) + +# Hosts considered loopback and therefore allowed over plain HTTP. +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + +# Platform domains susceptible to subdomain takeover. +_PLATFORM_DOMAINS = (".azurewebsites.net",) + + +def _is_custom_scheme_uri(uri: str) -> bool: + """Return True if the URI uses a known custom scheme for public clients. + + Args: + uri: The redirect URI string. + + Returns: + True when the scheme is a recognised custom/native scheme that should + not be validated further. + """ + if ":" not in uri: + return False + # Compare against the scheme only, never against the rest of the URI. + scheme = uri.split(":", 1)[0].lower() + if scheme == "urn": + return True + return any( + scheme.startswith(prefix) for prefix in _PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES + ) + + +def _check_uri(uri: str) -> str | None: + """Evaluate a single redirect URI for insecure patterns. + + Args: + uri: The redirect URI string. + + Returns: + A reason string when the URI is insecure, or None when it is safe. + """ + # Wildcards are never legitimate, not even in native schemes, so this + # runs first (and before parsing, since wildcards may break urlparse). + if "*" in uri: + return "contains wildcard" + + # Skip recognised custom schemes (public-client / native). + if _is_custom_scheme_uri(uri): + return None + + try: + parsed = urlparse(uri) + except Exception: + return "malformed URI" + + scheme = (parsed.scheme or "").lower() + host = (parsed.hostname or "").lower() + + # A URI that has no scheme or host is malformed. + if not scheme or not host: + return "malformed URI" + + # urlparse accepts invalid ports; reading .port is what validates them. + try: + parsed.port + except ValueError: + return "malformed URI" + + # Plain HTTP with a non-loopback host. + if scheme == "http" and host not in _LOOPBACK_HOSTS: + return "uses http:// with non-localhost host" + + # Platform domain susceptible to subdomain takeover. + for domain in _PLATFORM_DOMAINS: + if host == domain.lstrip(".") or host.endswith(domain): + return ( + f"host on shared platform domain {domain.lstrip('.')}, " + "subdomain takeover risk if the App Service is removed" + ) + + return None + + +class entra_app_registration_redirect_uris_secure(Check): + """Ensure application registration redirect URIs are secure. + + This check evaluates every application registration in the tenant and + inspects its web, SPA and public-client redirect URIs for insecure + patterns: plain HTTP on non-loopback hosts, hosts on shared platform + domains (e.g. azurewebsites.net), wildcard characters, and malformed URIs. + + - PASS: None of the application's redirect URIs match a FAIL condition, + or the application has no redirect URIs configured. + - FAIL: At least one redirect URI is insecure; every offending URI and its + reason are listed in ``status_extended``. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the redirect URI security check. + + Returns: + A list of reports containing the result of the check. + """ + findings: list[CheckReportM365] = [] + + for app_id, app in entra_client.app_registrations.items(): + display_name = app.name or app.app_id + report = CheckReportM365( + metadata=self.metadata(), + resource=app, + resource_name=display_name, + resource_id=app_id, + ) + + # Collect all redirect URIs across platforms. + all_uris = ( + app.web_redirect_uris + + app.spa_redirect_uris + + app.public_client_redirect_uris + ) + + # Evaluate each URI. + insecure: list[str] = [] + for uri in all_uris: + reason = _check_uri(uri) + if reason: + insecure.append(f"{uri} ({reason})") + + if insecure: + report.status = "FAIL" + total = len(insecure) + if total > 5: + displayed = ", ".join(insecure[:5]) + displayed += f" (and {total - 5} more)" + else: + displayed = ", ".join(insecure) + report.status_extended = ( + f"App registration {display_name} has {total} insecure " + f"redirect URI(s): {displayed}." + ) + else: + report.status = "PASS" + if all_uris: + report.status_extended = ( + f"App registration {display_name} has all redirect URIs secure." + ) + else: + report.status_extended = f"App registration {display_name} has no redirect URIs configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/__init__.py b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json new file mode 100644 index 0000000000..0fd7fe46a5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_cross_tenant_access_default_outbound_restricted", + "CheckTitle": "Default cross-tenant access policy restricts outbound B2B collaboration and direct connect", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The default cross-tenant access policy should **not** allow every user and group to access every application of any external tenant through B2B collaboration outbound or B2B direct connect outbound.\n\nOutbound access should be scoped to specific groups and applications, or blocked by default and opened per partner organization.", + "Risk": "With unrestricted outbound defaults, any user can join any external organization, accept B2B invitations, or use Teams shared channels via direct connect without approval. This widens **data exfiltration paths**, lets attackers lure users into attacker-controlled tenants, and removes control over which external organizations the tenant collaborates with.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/external-id/cross-tenant-access-overview", + "https://learn.microsoft.com/entra/external-id/cross-tenant-access-settings-b2b-collaboration" + ], + "Remediation": { + "Code": { + "CLI": "Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -Body '{\"b2bCollaborationOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}, \"b2bDirectConnectOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}}'", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **External Identities** > **Cross-tenant access settings**\n3. Select the **Default settings** tab\n4. Under **Outbound access settings**, click **Edit outbound defaults**\n5. On the **B2B collaboration** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n6. On the **B2B direct connect** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict default outbound cross-tenant access to specific groups and applications, or block it by default and allow collaboration per partner organization using partner-specific policies.", + "Url": "https://hub.prowler.com/check/entra_cross_tenant_access_default_outbound_restricted" + } + }, + "Categories": [ + "trust-boundaries", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_policy_guest_invitations_restricted_to_allowed_domains" + ], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py new file mode 100644 index 0000000000..63c0b2fe01 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py @@ -0,0 +1,161 @@ +from typing import List, Optional, Set + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + CrossTenantB2BSetting, + CrossTenantTargetConfiguration, +) + +# Targets that cover every user or every application. AllMicrosoftApps is +# treated as broad too: allowing it opens and blocking it closes access to +# every Microsoft cloud application. +ALL_USERS_TARGETS = {"AllUsers"} +ALL_APPLICATIONS_TARGETS = {"AllApplications", "AllMicrosoftApps"} + + +class entra_cross_tenant_access_default_outbound_restricted(Check): + """Ensure default cross-tenant access outbound settings are restricted. + + The default cross-tenant access policy must not allow every user and group + to access every application of any external tenant through B2B collaboration + outbound or B2B direct connect outbound. + + - PASS: Neither b2bCollaborationOutbound nor b2bDirectConnectOutbound is + fully open (allowed for AllUsers AND AllApplications). + - FAIL: At least one of the outbound settings allows all users and all + applications. + - MANUAL: The default policy or one of its outbound settings could not be + read or is incomplete, and no setting is fully open. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the cross-tenant access default outbound restriction check. + + Evaluates the tenant's default cross-tenant access policy to determine + whether B2B collaboration outbound and/or B2B direct connect outbound + are unrestricted (allowed for all users and all applications). + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + policy = entra_client.cross_tenant_access_default + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy if policy else {}, + resource_name="Cross-Tenant Access Default Policy", + resource_id="crossTenantAccessPolicyConfigurationDefault", + ) + + if policy is None: + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate the default cross-tenant access outbound settings: " + "the policy could not be read. " + "Verify that the Policy.Read.All permission is granted to the scanning application." + ) + findings.append(report) + return findings + + collaboration_open = self._is_fully_open(policy.b2b_collaboration_outbound) + direct_connect_open = self._is_fully_open(policy.b2b_direct_connect_outbound) + + unrestricted_settings = [] + if collaboration_open: + unrestricted_settings.append("B2B collaboration outbound") + if direct_connect_open: + unrestricted_settings.append("B2B direct connect outbound") + + if unrestricted_settings: + report.status = "FAIL" + joined = " and ".join(unrestricted_settings) + report.status_extended = ( + f"Default cross-tenant access policy leaves {joined} broadly open: " + f"access is not limited to specific users or groups and to specific " + f"applications in external tenants." + ) + elif collaboration_open is None or direct_connect_open is None: + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate the default cross-tenant access outbound settings: " + "the users and groups or applications configuration of an outbound " + "setting is incomplete." + ) + else: + report.status = "PASS" + report.status_extended = ( + "Default cross-tenant access policy restricts outbound access " + "for both B2B collaboration and B2B direct connect." + ) + + findings.append(report) + return findings + + @staticmethod + def _is_fully_open(setting: Optional[CrossTenantB2BSetting]) -> Optional[bool]: + """Determine whether a B2B outbound setting is broadly open. + + In the default policy a ``blocked`` list is an exception list: blocking + specific users, groups or applications leaves everything else allowed. + A dimension is therefore restricted only when it is ``allowed`` for + specific targets or ``blocked`` for everyone. The setting is open when + neither users and groups nor applications are restricted. + + Args: + setting: The B2B setting to evaluate, or None if absent. + + Returns: + True if the setting is broadly open, False if users or applications + are restricted, or None if its configuration is missing or incomplete. + """ + if setting is None: + return None + + configurations = (setting.users_and_groups, setting.applications) + if any( + config is None + or config.access_type not in ("allowed", "blocked") + or not config.targets + for config in configurations + ): + return None + + users_open = ( + entra_cross_tenant_access_default_outbound_restricted._dimension_open( + setting.users_and_groups, + allowed_all_targets=ALL_USERS_TARGETS, + blocked_all_targets=ALL_USERS_TARGETS, + ) + ) + apps_open = ( + entra_cross_tenant_access_default_outbound_restricted._dimension_open( + setting.applications, + allowed_all_targets=ALL_APPLICATIONS_TARGETS, + blocked_all_targets=ALL_APPLICATIONS_TARGETS, + ) + ) + return users_open and apps_open + + @staticmethod + def _dimension_open( + config: CrossTenantTargetConfiguration, + allowed_all_targets: Set[str], + blocked_all_targets: Set[str], + ) -> bool: + """Tell whether one dimension (users or applications) is left open. + + Args: + config: The users-and-groups or applications configuration. + allowed_all_targets: Targets that open the dimension when allowed. + blocked_all_targets: Targets that close the dimension when blocked. + + Returns: + True if everyone (or every application) can still be reached. + """ + targets = {target.target for target in config.targets} + if config.access_type == "allowed": + return bool(targets & allowed_all_targets) + # blocked: only blocking everyone restricts the dimension. + return not targets & blocked_all_targets diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json new file mode 100644 index 0000000000..6019b03cd8 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_dynamic_group_member_of_rule_not_used", + "CheckTitle": "Dynamic group rules prevent stale access by avoiding the retiring memberOf operator", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "This check verifies that dynamic membership groups in Microsoft Entra ID do not use the **memberOf** rule operator (`user.memberof` / `device.memberof`).\n\nMicrosoft is retiring this public-preview operator on November 3, 2026. Groups that still use it will stop updating and remain frozen in their last known membership.", + "Risk": "A frozen dynamic group retains every user or device from retirement time and never updates membership. Departed or re-roled members keep app assignments, Teams/SharePoint access, group-based licences, and **Conditional Access** targeting (inclusion or exclusion). This creates a silent, growing access-control gap.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-rule-member-of", + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-membership", + "https://maester.dev/docs/tests/MT.1197" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgGroup -GroupId -MembershipRule '' -MembershipRuleProcessingState On", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com/)\n2. Navigate to Identity > Groups > All groups\n3. Select the affected dynamic group\n4. Open Dynamic membership rules\n5. Replace the memberOf expression with attribute-based rules (e.g. user.department, user.extensionAttributeN, user.employeeType)\n6. Alternatively, convert the group to Assigned membership under Properties > Membership type\n7. Validate the resulting membership and every app, Conditional Access policy, licence, and SharePoint/Teams assignment that uses the group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace the **memberOf** operator in dynamic group rules with attribute-based expressions (e.g. `user.department`, `user.extensionAttributeN`) or convert the group to **Assigned** membership before the operator retirement date. Validate all dependent policies and assignments after migration.", + "Url": "https://hub.prowler.com/check/entra_dynamic_group_member_of_rule_not_used" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_dynamic_group_for_guests_created" + ], + "Notes": "Ported from Maester control MT.1197 (Test-MtDynamicGroupMemberOfRule, MIT licensed). The check does not depend on the current date for PASS/FAIL; the date only changes the wording in status_extended." +} diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py new file mode 100644 index 0000000000..c808080893 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py @@ -0,0 +1,157 @@ +"""Check that dynamic membership groups do not use the retiring memberOf rule operator.""" + +import re +from datetime import date +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Pre-compiled patterns ------------------------------------------------- +# After stripping quoted literals, detect the memberOf property reference. +_MEMBEROF_PROPERTY_RE = re.compile(r"(?i)\b(user|device)\.memberof\b") + +# Extract GUIDs that appear in a memberOf expression (on the original rule). +_GUID_RE = re.compile(r"(?i)\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b") + +# Matches single- or double-quoted string literals to strip before detection. +# Entra rules escape quotes inside literals with a backtick (e.g. "a`"b"). +_QUOTED_LITERAL_RE = re.compile(r"""('(?:`.|[^'`])*'|"(?:`.|[^"`])*")""") + +# The date Microsoft retires the memberOf preview operator. +_RETIREMENT_DATE = date(2026, 11, 3) + + +def _rule_uses_memberof(rule: str) -> bool: + """Return True if *rule* references the memberOf property outside quoted literals. + + Stripping quoted strings first avoids false positives like + ``user.department -eq "user.memberOf"``. + """ + stripped = _QUOTED_LITERAL_RE.sub("", rule) + return bool(_MEMBEROF_PROPERTY_RE.search(stripped)) + + +def _extract_source_group_ids(rule: str) -> List[str]: + """Return deduplicated GUIDs found after a memberOf reference in the original rule. + + GUIDs are collected from the full (unstripped) rule and deduplicated + case-insensitively while preserving the original casing. + """ + # Find the position of the first memberOf property reference. + match = _MEMBEROF_PROPERTY_RE.search(rule) + if not match: + return [] + + after_memberof = rule[match.end() :] + seen: set = set() + result: list = [] + for guid_match in _GUID_RE.finditer(after_memberof): + guid = guid_match.group() + lower = guid.lower() + if lower not in seen: + seen.add(lower) + result.append(guid) + return result + + +class entra_dynamic_group_member_of_rule_not_used(Check): + """Ensure dynamic membership groups do not use the retiring memberOf rule operator. + + This check evaluates every Entra ID group whose ``groupTypes`` contains + ``DynamicMembership``. The ``memberOf`` operator + (``user.memberof`` / ``device.memberof``) is a public preview that + Microsoft is ending on November 3, 2026. After that date, dynamic groups + still using the operator stop updating and stay frozen in their last known + membership, creating a silent access-control gap. + + - PASS: The dynamic group's membership rule does not reference the + ``memberOf`` property, or the group has no membership rule. + - FAIL: The dynamic group's membership rule references ``user.memberof`` + or ``device.memberof``. + - MANUAL: Groups could not be read (permissions, throttling, API error). + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the memberOf rule check for all dynamic membership groups. + + Returns: + A list of reports, one per dynamic membership group evaluated. + """ + findings: List[CheckReportM365] = [] + + # If groups could not be retrieved, emit a single tenant-level MANUAL. + if entra_client.groups_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Dynamic groups", + resource_id="groups", + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate dynamic group memberOf rules: " + f"{entra_client.groups_error}." + ) + findings.append(report) + return findings + + today = date.today() + + for group in entra_client.groups: + if "DynamicMembership" not in group.groupTypes: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=group, + resource_name=group.name, + resource_id=group.id, + ) + + # A dynamic group without a rule cannot use the memberOf operator. + if not group.membershipRule: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} has no membership rule, so it " + f"does not use the memberOf rule operator." + ) + findings.append(report) + continue + + # Normalise CR/LF to spaces for matching. + normalised_rule = group.membershipRule.replace("\r", " ").replace("\n", " ") + + if _rule_uses_memberof(normalised_rule): + processing_state = group.membership_rule_processing_state or "Unknown" + source_ids = _extract_source_group_ids(normalised_rule) + source_ids_text = ( + f" referencing source group(s): {', '.join(source_ids)}" + if source_ids + else "" + ) + + if today < _RETIREMENT_DATE: + retirement_text = "is being retired on November 3, 2026" + else: + retirement_text = ( + "was retired on November 3, 2026 and the group " + "membership may already be stale" + ) + + report.status = "FAIL" + report.status_extended = ( + f"Dynamic group {group.name} (processing: {processing_state}) " + f"uses the memberOf rule operator which {retirement_text}" + f"{source_ids_text}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} does not use the memberOf " + f"rule operator." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json new file mode 100644 index 0000000000..5e79cf9fe6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_admin_self_service_password_reset_disabled", + "CheckTitle": "Disabled Self-Service Password Reset protects administrator accounts from unauthorized recovery", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authorization policy property `allowedToUseSSPR` controls whether administrators can reset their own passwords through **Self-Service Password Reset (SSPR)**.\n\nWhen disabled, password recovery for privileged accounts must go through other administrators or break-glass procedures, reducing the risk of account takeover via compromised recovery methods.", + "Risk": "If SSPR is enabled for administrators, anyone who controls an admin's registered recovery methods (e.g., **SIM swap**, compromised alternate mailbox, social engineering) can reset the admin password and take over a **privileged account** without going through a controlled recovery process.\n\nThis directly threatens the **confidentiality** and **integrity** of the entire tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity/authentication/concept-sspr-policy#administrator-reset-policy-differences", + "https://learn.microsoft.com/graph/api/resources/authorizationpolicy", + "https://learn.microsoft.com/graph/api/authorizationpolicy-get" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false", + "NativeIaC": "", + "Other": "Administrator SSPR cannot be disabled from the Microsoft Entra admin center: the **Password reset** settings there only apply to non-administrator users. Use Microsoft Graph PowerShell instead:\n1. Connect-MgGraph -Scopes Policy.ReadWrite.Authorization\n2. Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false\n3. Verify with (Get-MgPolicyAuthorizationPolicy).AllowedToUseSspr", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Self-Service Password Reset** for administrator accounts. Privileged account password recovery should be handled by other administrators or **break-glass procedures** to prevent account takeover through compromised recovery methods.\n\nEnsure a documented recovery path exists before disabling SSPR for admins.", + "Url": "https://hub.prowler.com/check/entra_policy_admin_self_service_password_reset_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The service default for allowedToUseSSPR is true, so most tenants that never changed it will FAIL. Before disabling, ensure there is a documented recovery path for admin passwords (other admins, break-glass accounts). Requires Policy.ReadWrite.Authorization to remediate." +} diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py new file mode 100644 index 0000000000..f1d3a5957f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py @@ -0,0 +1,64 @@ +"""Check that Self-Service Password Reset is disabled for administrators.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_admin_self_service_password_reset_disabled(Check): + """Ensure that Self-Service Password Reset (SSPR) is disabled for administrators. + + This check evaluates the tenant authorization policy to verify that the + ``allowedToUseSSPR`` property is set to ``false``, preventing administrators + from resetting their own passwords through SSPR. + + - PASS: ``allowedToUseSSPR`` is ``false`` (administrators cannot use SSPR). + - FAIL: ``allowedToUseSSPR`` is ``true`` (SSPR is enabled for administrators). + - MANUAL: The authorization policy or the property could not be read. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate allowedToUseSSPR on the tenant authorization policy.""" + findings = [] + auth_policy = entra_client.authorization_policy + + if auth_policy is None or auth_policy.allowed_to_use_sspr is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy if auth_policy else {}, + resource_name="Authorization Policy", + resource_id="authorizationPolicy", + ) + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate whether SSPR is disabled for administrators: the " + "authorization policy or its allowedToUseSSPR setting could not be " + "retrieved. Check the scanning application's permissions and any " + "Microsoft Graph API errors in the scan log." + ) + findings.append(report) + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy, + resource_name=auth_policy.name, + resource_id=auth_policy.id, + ) + + if not auth_policy.allowed_to_use_sspr: + report.status = "PASS" + report.status_extended = ( + "Self-Service Password Reset (SSPR) is disabled for administrators " + "(allowedToUseSSPR is false in the authorization policy)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Self-Service Password Reset (SSPR) is enabled for administrators " + "(allowedToUseSSPR is true in the authorization policy)." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json new file mode 100644 index 0000000000..0268918f8a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_msol_powershell_blocked", + "CheckTitle": "Tenant blocks the legacy MSOnline (MSOL) PowerShell module", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authorization policy property `blockMsolPowerShell` controls whether the retired **MSOnline (MSOL) PowerShell** module and its legacy service endpoint are accessible. When set to `true`, user-based access through that endpoint is disabled without affecting Microsoft Entra Connect or Microsoft Graph PowerShell.", + "Risk": "The MSOnline and AzureAD PowerShell modules are **retired** and no longer receive security updates. They use a legacy endpoint that is less monitored than Microsoft Graph, providing an **unsupported administrative channel** to identity data and configuration. Attackers or insiders could exploit this path to read or modify directory objects while bypassing modern controls and audit coverage.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/graph/api/resources/authorizationpolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/powershell/azure/active-directory/overview", + "https://learn.microsoft.com/powershell/microsoftgraph/migration-steps", + "https://maester.dev/docs/tests/MT.1185" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }", + "NativeIaC": "", + "Other": "1. Connect to Microsoft Graph PowerShell with the required scope:\n `Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\"`\n2. Run:\n `Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }`\n3. Verify the change:\n `(Get-MgPolicyAuthorizationPolicy).BlockMsolPowerShell`\n\n*Note: There is no Entra admin center toggle for this setting. Before enabling, migrate any remaining scripts from MSOnline/AzureAD to Microsoft Graph PowerShell.*", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block the legacy MSOnline PowerShell endpoint by setting `blockMsolPowerShell` to **true** in the authorization policy. Before enabling, migrate all automation and scripts from the retired MSOnline/AzureAD modules to **Microsoft Graph PowerShell**. This does not affect Microsoft Entra Connect sync or Microsoft Graph-based tooling.", + "Url": "https://hub.prowler.com/check/entra_policy_msol_powershell_blocked" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The blockMsolPowerShell setting is not enabled by default in every tenant and must be verified explicitly. It does not affect Microsoft Entra Connect or Microsoft Graph PowerShell." +} diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py new file mode 100644 index 0000000000..935235a011 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py @@ -0,0 +1,67 @@ +"""Check if legacy MSOnline (MSOL) PowerShell module is blocked in the tenant.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_msol_powershell_blocked(Check): + """Ensure legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy. + + This check verifies the `block_msol_powershell` property of the authorization policy. + The MSOnline and AzureAD PowerShell modules are retired and use a legacy endpoint + that is less monitored than Microsoft Graph. Blocking them removes an unsupported + administrative access channel. + + - PASS: blockMsolPowerShell is true. + - FAIL: blockMsolPowerShell is false. + - MANUAL: The authorization policy could not be read or the property is missing/null. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the MSOL PowerShell blocked check. + + Retrieves the authorization policy from the Microsoft Entra client and checks + whether the 'block_msol_powershell' property is set to true. + + Returns: + List[CheckReportM365]: A list containing a single check report with + the pass/fail/manual status and description. + """ + findings = [] + auth_policy = entra_client.authorization_policy + + if auth_policy is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Authorization Policy", + resource_id="authorizationPolicy", + ) + report.status = "MANUAL" + report.status_extended = "Cannot evaluate whether legacy MSOnline (MSOL) PowerShell is blocked: the authorization policy could not be retrieved. Check the scanning application's permissions and any Microsoft Graph API errors in the scan log." + findings.append(report) + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy, + resource_name=auth_policy.name, + resource_id=auth_policy.id, + ) + + block_msol = auth_policy.block_msol_powershell + + if block_msol is None: + report.status = "MANUAL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell blocked status could not be determined from the tenant authorization policy." + elif block_msol: + report.status = "PASS" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + else: + report.status = "FAIL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index c761834195..aaa7072af5 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -49,6 +49,7 @@ class Entra(M365Service): oauth_apps (dict): Dictionary of OAuth applications from Defender XDR. authentication_method_configurations (dict): Dictionary of authentication method configurations. service_principals (dict): Dictionary of service principals with credentials and role assignments. + cross_tenant_access_default (CrossTenantAccessDefault): The default cross-tenant access policy configuration. """ def __init__(self, provider: M365Provider): @@ -90,6 +91,10 @@ class Entra(M365Service): # dependencies) fails, so checks can report that users are unavailable # instead of silently evaluating an empty directory. self.users_error: Optional[str] = None + # Set when the Microsoft Graph /groups request fails (permissions, + # throttling, mid-pagination error), so checks can emit a MANUAL + # finding instead of silently evaluating an empty group list. + self.groups_error: Optional[str] = None self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( @@ -114,6 +119,7 @@ class Entra(M365Service): self._get_authentication_methods_policy_settings(), self._get_pim_role_approval_settings(), self._get_access_review_definitions(), + self._get_cross_tenant_access_default(), ) ) @@ -150,6 +156,9 @@ class Entra(M365Service): 19 ] self.access_review_definitions: List[AccessReviewDefinition] = attributes[20] + self.cross_tenant_access_default: Optional[CrossTenantAccessDefault] = ( + attributes[21] + ) self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -227,6 +236,12 @@ class Entra(M365Service): ), guest_invite_settings=auth_policy.allow_invites_from, guest_user_role_id=auth_policy.guest_user_role_id, + allowed_to_use_sspr=getattr( + auth_policy, "allowed_to_use_s_s_p_r", None + ), + block_msol_powershell=getattr( + auth_policy, "block_msol_power_shell", None + ), ) except Exception as error: logger.error( @@ -807,6 +822,14 @@ class Entra(M365Service): ) async def _get_groups(self): + """Retrieve all Entra ID groups via Microsoft Graph. + + Fetches groups with their core properties including dynamic membership + configuration. On any API error (permissions, throttling, + mid-pagination failure) the partial result collected so far is returned + **and** ``self.groups_error`` is set so that checks can emit a MANUAL + finding instead of silently evaluating an incomplete group list. + """ logger.info("Entra - Getting groups...") groups = [] try: @@ -817,6 +840,7 @@ class Entra(M365Service): "displayName", "groupTypes", "membershipRule", + "membershipRuleProcessingState", "isAssignableToRole", "isManagementRestricted", ], @@ -837,6 +861,9 @@ class Entra(M365Service): name=group.display_name, groupTypes=group.group_types or [], membershipRule=group.membership_rule, + membership_rule_processing_state=getattr( + group, "membership_rule_processing_state", None + ), is_assignable_to_role=getattr( group, "is_assignable_to_role", False ) @@ -851,11 +878,25 @@ class Entra(M365Service): next_link = getattr(groups_data, "odata_next_link", None) if not next_link: break - groups_data = await self.client.groups.with_url(next_link).get() + try: + groups_data = await self.client.groups.with_url(next_link).get() + except Exception as pagination_error: + logger.error( + f"{pagination_error.__class__.__name__}[{pagination_error.__traceback__.tb_lineno}]: {pagination_error}" + ) + self.groups_error = ( + f"Groups list may be incomplete due to a pagination error: " + f"{pagination_error.__class__.__name__}" + ) + break except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.groups_error = ( + f"Unable to retrieve groups from Microsoft Graph " + f"({error.__class__.__name__})" + ) return groups async def _get_organization(self): @@ -1551,6 +1592,86 @@ OAuthAppInfo ) return b2b_policy + async def _get_cross_tenant_access_default(self): + """Retrieve the default cross-tenant access policy configuration. + + Fetches the tenant's default cross-tenant access policy from + ``policies/crossTenantAccessPolicy/default`` to determine whether + B2B collaboration outbound and B2B direct connect outbound settings + are unrestricted (allowed for all users and all applications). + + Returns: + Optional[CrossTenantAccessDefault]: The parsed default cross-tenant + access policy, or None on error. + """ + logger.info("Entra - Getting cross-tenant access default policy...") + cross_tenant_default = None + try: + policy = await self.client.policies.cross_tenant_access_policy.default.get() + if policy: + cross_tenant_default = CrossTenantAccessDefault( + b2b_collaboration_outbound=self._parse_b2b_setting( + policy.b2b_collaboration_outbound + ), + b2b_direct_connect_outbound=self._parse_b2b_setting( + policy.b2b_direct_connect_outbound + ), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return cross_tenant_default + + @staticmethod + def _parse_b2b_setting(setting) -> Optional["CrossTenantB2BSetting"]: + """Parse a crossTenantAccessPolicyB2BSetting into a model. + + Extracts the ``usersAndGroups`` and ``applications`` target configuration + from a Graph SDK B2B setting object and converts them into a + ``CrossTenantB2BSetting`` model. + + Args: + setting: A Graph SDK ``CrossTenantAccessPolicyB2BSetting`` object, + or None if the setting is absent. + + Returns: + Optional[CrossTenantB2BSetting]: The parsed B2B setting, or None + if the input is None. + """ + if setting is None: + return None + + def parse_target_config(config) -> Optional["CrossTenantTargetConfiguration"]: + """Parse a target configuration (usersAndGroups or applications).""" + if config is None: + return None + access_type = None + if config.access_type is not None: + access_type = config.access_type.value + targets = [] + if config.targets: + for t in config.targets: + target_value = getattr(t, "target", None) + target_type = getattr(t, "target_type", None) + if target_type is not None: + target_type = target_type.value + targets.append( + CrossTenantTarget( + target=target_value, + target_type=target_type, + ) + ) + return CrossTenantTargetConfiguration( + access_type=access_type, + targets=targets, + ) + + return CrossTenantB2BSetting( + users_and_groups=parse_target_config(setting.users_and_groups), + applications=parse_target_config(setting.applications), + ) + async def _get_named_locations(self): """Retrieve Conditional Access named locations from Microsoft Entra. @@ -2116,11 +2237,34 @@ OAuthAppInfo ) ) + # Extract redirect URIs from web, spa and publicClient + web_obj = getattr(app, "web", None) + web_redirect_uris = ( + list(getattr(web_obj, "redirect_uris", []) or []) + if web_obj + else [] + ) + spa_obj = getattr(app, "spa", None) + spa_redirect_uris = ( + list(getattr(spa_obj, "redirect_uris", []) or []) + if spa_obj + else [] + ) + public_client_obj = getattr(app, "public_client", None) + public_client_redirect_uris = ( + list(getattr(public_client_obj, "redirect_uris", []) or []) + if public_client_obj + else [] + ) + app_registrations[object_id] = AppRegistration( id=object_id, app_id=app_id, name=getattr(app, "display_name", "") or "", password_credentials=password_credentials, + web_redirect_uris=web_redirect_uris, + spa_redirect_uris=spa_redirect_uris, + public_client_redirect_uris=public_client_redirect_uris, ) next_link = getattr(app_response, "odata_next_link", None) @@ -2510,12 +2654,16 @@ class DefaultUserRolePermissions(BaseModel): class AuthorizationPolicy(BaseModel): + """Tenant-wide authorization policy from Microsoft Entra ID.""" + id: str name: str description: str default_user_role_permissions: Optional[DefaultUserRolePermissions] guest_invite_settings: Optional[str] guest_user_role_id: Optional[UUID] + allowed_to_use_sspr: Optional[bool] = None + block_msol_powershell: Optional[bool] = None # Well-known directory setting template IDs (from /groupSettings). @@ -2643,10 +2791,13 @@ class AuthenticationMethodConfiguration(BaseModel): class Group(BaseModel): + """Model representing a Microsoft Entra ID group.""" + id: str name: str groupTypes: List[str] membershipRule: Optional[str] + membership_rule_processing_state: Optional[str] = None is_assignable_to_role: bool = False is_management_restricted: bool = False @@ -2929,9 +3080,69 @@ class AppRegistration(BaseModel): name: The application's display name. password_credentials: List of password credentials (client secrets) registered on the application. + web_redirect_uris: Redirect URIs configured for the web platform. + spa_redirect_uris: Redirect URIs configured for the SPA platform. + public_client_redirect_uris: Redirect URIs configured for the + public client (mobile/desktop) platform. """ id: str app_id: str = "" name: str = "" password_credentials: List[PasswordCredential] = [] + web_redirect_uris: List[str] = [] + spa_redirect_uris: List[str] = [] + public_client_redirect_uris: List[str] = [] + + +class CrossTenantTarget(BaseModel): + """A single target entry in a cross-tenant access target configuration. + + Attributes: + target: The target identifier (e.g. ``AllUsers``, ``AllApplications``, + or a specific group/application ID). + target_type: The type of the target (e.g. ``user``, ``application``). + """ + + target: Optional[str] = None + target_type: Optional[str] = None + + +class CrossTenantTargetConfiguration(BaseModel): + """Target configuration for users/groups or applications in a B2B setting. + + Attributes: + access_type: The access type (``allowed``, ``blocked``, or + ``unknownFutureValue``). None if not set. + targets: List of targets for this configuration. + """ + + access_type: Optional[str] = None + targets: List[CrossTenantTarget] = [] + + +class CrossTenantB2BSetting(BaseModel): + """A B2B setting (collaboration or direct connect) within the cross-tenant policy. + + Attributes: + users_and_groups: Target configuration for users and groups. + applications: Target configuration for applications. + """ + + users_and_groups: Optional[CrossTenantTargetConfiguration] = None + applications: Optional[CrossTenantTargetConfiguration] = None + + +class CrossTenantAccessDefault(BaseModel): + """Default cross-tenant access policy configuration. + + Represents the tenant's default cross-tenant access policy, specifically + the B2B collaboration outbound and B2B direct connect outbound settings. + + Attributes: + b2b_collaboration_outbound: The outbound B2B collaboration setting. + b2b_direct_connect_outbound: The outbound B2B direct connect setting. + """ + + b2b_collaboration_outbound: Optional[CrossTenantB2BSetting] = None + b2b_direct_connect_outbound: Optional[CrossTenantB2BSetting] = None diff --git a/pyproject.toml b/pyproject.toml index a2173c3ac7..5dfd308ab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "azure-keyvault-keys==4.10.0", "azure-mgmt-applicationinsights==4.1.0", "azure-mgmt-authorization==4.0.0", + "azure-mgmt-cognitiveservices==14.1.0", "azure-mgmt-compute==34.0.0", "azure-mgmt-containerregistry==12.0.0", "azure-mgmt-containerservice==34.1.0", @@ -144,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.45.0" +version = "5.46.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/tests/lib/network/ssrf_test.py b/tests/lib/network/ssrf_test.py new file mode 100644 index 0000000000..6d3633b2bf --- /dev/null +++ b/tests/lib/network/ssrf_test.py @@ -0,0 +1,257 @@ +import ipaddress +import os +import socket +from unittest import mock + +import pytest + +from prowler.lib.network.ssrf import ( + ALLOWED_PRIVATE_NETWORKS_ENV, + SKIP_OUTBOUND_CHECK_ENV, + OutboundURLNotAllowedError, + allowed_private_networks, + extract_host, + outbound_check_skipped, + validate_outbound_host, + validate_outbound_url, +) + + +def _resolves_to(*addresses): + return mock.patch.object( + socket, + "getaddrinfo", + return_value=[(2, 1, 6, "", (address, 0)) for address in addresses], + ) + + +class TestValidateOutboundHost: + @pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.5", + "192.168.1.1", + "172.16.0.1", + "169.254.169.254", + "0.0.0.0", + "224.0.0.1", + "198.18.0.1", + "203.0.113.1", + "::1", + "fe80::1", + "fc00::1", + "ff02::1", + "2001:db8::1", + ], + ) + def test_rejects_non_public_literals(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", ["100.64.0.1", "100.100.100.200", "100.127.255.254"] + ) + def test_rejects_shared_address_space(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", ["8.8.8.8", "1.1.1.1", "140.82.121.4", "2606:4700:4700::1111"] + ) + def test_allows_public_literals(self, address): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", + ["::ffff:169.254.169.254", "2002:a00:5::", "64:ff9b::a00:5"], + ) + def test_rejects_ipv6_embedding_a_non_public_ipv4(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + def test_rejects_a_hostname_resolving_to_a_private_address(self): + with _resolves_to("10.0.0.5"): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("registry.internal") + + def test_rejects_a_hostname_with_one_non_public_answer(self): + with _resolves_to("8.8.8.8", "127.0.0.1"): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("split-horizon.example.com") + + def test_allows_a_hostname_resolving_to_a_public_address(self): + with _resolves_to("140.82.121.4"): + validate_outbound_host("github.com") + + def test_rejects_a_hostname_that_does_not_resolve(self): + with mock.patch.object( + socket, "getaddrinfo", side_effect=socket.gaierror("no such host") + ): + with pytest.raises(OutboundURLNotAllowedError, match="Could not resolve"): + validate_outbound_host("nowhere.invalid") + + +class TestOutboundCheckOptOut: + def test_is_enforced_when_the_variable_is_unset(self, monkeypatch): + monkeypatch.delenv(SKIP_OUTBOUND_CHECK_ENV, raising=False) + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("169.254.169.254") + + @pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"]) + def test_is_skipped_when_the_variable_is_truthy(self, monkeypatch, raw): + monkeypatch.setenv(SKIP_OUTBOUND_CHECK_ENV, raw) + validate_outbound_host("169.254.169.254") + + @pytest.mark.parametrize("raw", ["", "0", "false", "no", "off", "maybe"]) + def test_is_enforced_for_anything_else(self, monkeypatch, raw): + monkeypatch.setenv(SKIP_OUTBOUND_CHECK_ENV, raw) + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("169.254.169.254") + + def test_logs_the_skip_so_an_operator_sees_the_disabled_control(self, monkeypatch): + monkeypatch.setenv(SKIP_OUTBOUND_CHECK_ENV, "true") + + with mock.patch("prowler.lib.network.ssrf.logger") as logged: + validate_outbound_host("169.254.169.254") + + assert logged.warning.call_count == 1 + assert SKIP_OUTBOUND_CHECK_ENV in logged.warning.call_args.args[0] + + def test_the_cli_entrypoint_opts_out(self, monkeypatch): + """prowler() must set the opt-out before it does anything else.""" + # a throwaway mapping, so the variable prowler() sets cannot leak into + # the tests that assert the check is enforced + monkeypatch.setattr(os, "environ", dict(os.environ)) + os.environ.pop(SKIP_OUTBOUND_CHECK_ENV, None) + + import prowler.__main__ as prowler_main + + class _Stop(Exception): + pass + + def _parser_that_stops(): + # raised from the first statement after the opt-out, so reaching here + # proves the variable was already set + raise _Stop + + monkeypatch.setattr(prowler_main, "ProwlerArgumentParser", _parser_that_stops) + + with pytest.raises(_Stop): + prowler_main.prowler() + + assert outbound_check_skipped() + + @pytest.mark.parametrize("chosen", ["false", "0", "off", ""]) + def test_the_cli_entrypoint_respects_an_explicit_value(self, monkeypatch, chosen): + """setdefault, not assignment: an operator who opted in keeps the check.""" + monkeypatch.setattr(os, "environ", dict(os.environ)) + os.environ[SKIP_OUTBOUND_CHECK_ENV] = chosen + + import prowler.__main__ as prowler_main + + class _Stop(Exception): + pass + + def _parser_that_stops(): + raise _Stop + + monkeypatch.setattr(prowler_main, "ProwlerArgumentParser", _parser_that_stops) + + with pytest.raises(_Stop): + prowler_main.prowler() + + assert os.environ[SKIP_OUTBOUND_CHECK_ENV] == chosen + assert not outbound_check_skipped() + + +class TestAllowedPrivateNetworks: + def test_is_empty_when_unset(self, monkeypatch): + monkeypatch.delenv(ALLOWED_PRIVATE_NETWORKS_ENV, raising=False) + assert allowed_private_networks() == () + + @pytest.mark.parametrize("raw", ["", " ", ",", " , "]) + def test_is_empty_for_blank_values(self, monkeypatch, raw): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, raw) + assert allowed_private_networks() == () + + def test_parses_addresses_and_cidrs(self, monkeypatch): + monkeypatch.setenv( + ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16, 192.168.65.254 ,fc00::/7" + ) + assert allowed_private_networks() == ( + ipaddress.ip_network("10.20.0.0/16"), + ipaddress.ip_network("192.168.65.254/32"), + ipaddress.ip_network("fc00::/7"), + ) + + def test_rejects_a_malformed_entry(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16,not-a-network") + with pytest.raises(OutboundURLNotAllowedError, match="Malformed entry"): + allowed_private_networks() + + def test_allows_an_address_inside_an_allowlisted_range(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16") + validate_outbound_host("10.20.1.5") + + def test_still_rejects_an_address_outside_the_allowlisted_range(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16") + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("169.254.169.254") + + def test_does_not_match_an_allowlist_entry_of_another_family(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "fc00::/7") + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("10.20.1.5") + + +class TestExtractHost: + @pytest.mark.parametrize( + "url, expected", + [ + ("https://github.com/org/repo", "github.com"), + ( + "https://user:token@github.com/org/repo", # trufflehog:ignore + "github.com", + ), + ("https://github.com:8443/org/repo", "github.com"), + ("git@github.com:org/repo.git", "github.com"), + ("github.com:org/repo.git", "github.com"), + ("ssh://git@github.com/org/repo.git", "github.com"), + ], + ) + def test_reads_the_host(self, url, expected): + assert extract_host(url) == expected + + def test_rejects_a_url_without_a_host(self): + with pytest.raises(OutboundURLNotAllowedError, match="Could not read a host"): + extract_host("not a url") + + def test_keeps_the_url_out_of_the_rejection_message(self): + # the message is logged, and a configured URL can carry credentials + with pytest.raises(OutboundURLNotAllowedError) as rejection: + extract_host("https://user:s3cr3t@/org/repo.git") + + assert "s3cr3t" not in str(rejection.value) + assert "user" not in str(rejection.value) + + +class TestValidateOutboundURL: + def test_rejects_a_disallowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError, match="Disallowed URL scheme"): + validate_outbound_url("file:///etc/passwd") + + def test_allows_an_explicitly_permitted_scheme(self): + with _resolves_to("140.82.121.4"): + validate_outbound_url( + "ssh://git@github.com/org/repo.git", + allowed_schemes=("http", "https", "ssh", "git"), + ) + + def test_rejects_a_non_public_host_on_an_allowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_url("http://169.254.169.254/latest/meta-data") + + def test_rejects_shared_address_space_on_an_allowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_url("http://100.100.100.200/latest/meta-data") diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index 335a9ad17d..36ac977715 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -136,6 +136,28 @@ class TestAWSGenericCompliance: assert content == expected_csv + def test_batch_write_neutralises_formula_initiators(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + status_extended="=cmd", + resource_uid="-1", + compliance={"NIST-800-53-Revision-4": "ac_2_4"}, + ) + ] + output = GenericCompliance(findings, NIST_800_53_REVISION_4_AWS) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + header, row, _ = mock_file.read().splitlines() + cells = dict(zip(header.split(";"), row.split(";"))) + + assert cells["STATUSEXTENDED"] == "'=cmd" + assert cells["RESOURCEID"] == "-1" + def test_csv_row_count_matches_framework_checks_not_stored_compliance(self): """Regression test for PROWLER-1763. diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index dd951cf272..00f577060a 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -228,6 +228,34 @@ class TestCSVFileWrite: assert "REQUIREMENTS_ATTRIBUTES_SECTION" in content assert "IAM" in content + def test_batch_write_neutralises_formula_initiators(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + ] + fw = _make_framework(reqs, [AttributeMetadata(key="Section", type="str")]) + + finding = _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}) + finding.status_extended = "=cmd" + finding.resource_name = "-1" + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=[finding], framework=fw, file_path=filepath + ) + output.batch_write_data_to_file() + + with open(filepath, "r") as f: + header, row = f.read().splitlines() + cells = dict(zip(header.split(";"), row.split(";"))) + + assert cells["STATUSEXTENDED"] == "'=cmd" + assert cells["RESOURCENAME"] == "-1" + class TestNoFindings: def test_empty_findings_no_data(self, tmp_path): diff --git a/tests/lib/outputs/csv/csv_test.py b/tests/lib/outputs/csv/csv_test.py index b2b5d390df..dd132ad593 100644 --- a/tests/lib/outputs/csv/csv_test.py +++ b/tests/lib/outputs/csv/csv_test.py @@ -1,4 +1,5 @@ import tempfile +from csv import DictReader from datetime import datetime from io import StringIO, TextIOWrapper from typing import List @@ -131,6 +132,29 @@ class TestCSV: assert content == expected_csv + def test_csv_write_neutralises_formula_initiators(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + resource_name='=HYPERLINK("http://attacker.example")', + resource_details="-1", + resource_tags={"=cmd": "x", "env": "prod"}, + ) + ] + + output = CSV(findings) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + cells = next(DictReader(mock_file, delimiter=";")) + + assert cells["RESOURCE_NAME"] == '\'=HYPERLINK("http://attacker.example")' + assert cells["RESOURCE_DETAILS"] == "-1" + assert cells["RESOURCE_TAGS"] == "'=cmd=x | env=prod" + def test_batch_write_data_to_file_without_findings(self): assert not CSV([])._file_descriptor diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 1590d29742..6c39e43249 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -910,6 +910,23 @@ class TestHTML: assert "•key&<>'"<img" in output_data assert "=value&<>'"<img" in output_data + def test_transform_escapes_compliance(self): + xss_payload = '' + findings = [ + generate_finding_output( + compliance={f"Framework{xss_payload}-1.0": [xss_payload, "1.1"]}, + ) + ] + + output_data = HTML(findings).data[0] + + assert xss_payload not in output_data + assert ( + "•Framework<img src=x " + "onerror="window.PROWLER_COMPLIANCE_XSS=1">-1.0: " + "<img src=x onerror="window.PROWLER_COMPLIANCE_XSS=1">, 1.1" + ) in output_data + def test_transform_escapes_metadata_fields(self): finding = generate_finding_output() finding.metadata.Severity = MagicMock(value='') diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index fad6f4d286..b404e1c032 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -13,6 +13,7 @@ from prowler.lib.outputs.outputs import ( from prowler.lib.outputs.utils import ( parse_html_string, parse_json_tags, + sanitize_csv_value, unroll_dict, unroll_dict_to_list, unroll_list, @@ -1313,3 +1314,51 @@ class TestReport: with mock.patch("builtins.print") as mocked_print: report([finding], provider, output_options) mocked_print.assert_called() + + +class TestSanitizeCSVValue: + @pytest.mark.parametrize("initiator", ["=", "+", "-", "@", "\t", "\r"]) + def test_leading_formula_initiator_is_prefixed(self, initiator): + assert sanitize_csv_value(f"{initiator}cmd") == f"'{initiator}cmd" + + def test_value_containing_equals_is_untouched(self): + assert sanitize_csv_value("key=value") == "key=value" + + @pytest.mark.parametrize("number", ["-1", "+1", "-1.5", "-1e3"]) + def test_signed_number_is_untouched(self, number): + assert sanitize_csv_value(number) == number + + @pytest.mark.parametrize("value", [None, True, -1, -1.5, ""]) + def test_non_string_and_empty_values_are_untouched(self, value): + assert sanitize_csv_value(value) == value + + @pytest.mark.parametrize( + "value", ["-nan", "+nan", "-inf", "+inf", "-infinity", "+Infinity"] + ) + def test_non_finite_float_literal_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize("value", ["-1 ", "+1\t", "-1\n"]) + def test_padded_number_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize( + "payload", + [ + "=cmd|' /C calc'!A0", + "+cmd|' /C calc'!A0", + "-1+1|cmd|' /C calc'!A0", + "@SUM(1+1)*cmd|' /C calc'!A0", + "-0x1", + ], + ) + def test_formula_payload_is_prefixed(self, payload): + assert sanitize_csv_value(payload) == f"'{payload}" + + @pytest.mark.parametrize("value", [" =cmd", "\n=cmd", " -1+1|cmd", " \n@SUM(1)"]) + def test_whitespace_before_formula_initiator_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize("value", [" ", "\n", " text"]) + def test_whitespace_without_initiator_is_untouched(self, value): + assert sanitize_csv_value(value) == value diff --git a/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py b/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py new file mode 100644 index 0000000000..52375c67b8 --- /dev/null +++ b/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py @@ -0,0 +1,52 @@ +import csv +from unittest.mock import MagicMock + +from prowler.providers.aws.lib.quick_inventory.quick_inventory import create_output + + +def _args(tmp_path): + args = MagicMock() + args.output_directory = str(tmp_path) + args.output_filename = "inventory" + return args + + +def _provider(): + provider = MagicMock() + provider.identity.account = "123456789012" + return provider + + +def _rows(tmp_path): + with open(tmp_path / "inventory.csv", newline="") as handle: + return list(csv.reader(handle)) + + +class TestQuickInventoryCsvOutput: + def test_formula_initiator_in_tags_is_neutralised(self, tmp_path): + resources = [ + { + "arn": "arn:aws:s3:eu-west-1:123456789012:bucket-one", + "tags": '=cmd|" /C calc"!A0', + } + ] + + create_output(resources, _provider(), _args(tmp_path)) + + header, row = _rows(tmp_path) + assert row[header.index("AWS_Tags")] == '\'=cmd|" /C calc"!A0' + + def test_header_and_ordinary_values_are_untouched(self, tmp_path): + resources = [ + { + "arn": "arn:aws:s3:eu-west-1:123456789012:bucket-one", + "tags": "env=prod", + } + ] + + create_output(resources, _provider(), _args(tmp_path)) + + header, row = _rows(tmp_path) + assert header[0] == "AWS_AccountID" + assert row[header.index("AWS_Tags")] == "env=prod" + assert row[header.index("AWS_Region")] == "eu-west-1" diff --git a/tests/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled_test.py new file mode 100644 index 0000000000..8723d093d2 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_diagnostic_logging_enabled/aiservices_account_diagnostic_logging_enabled_test.py @@ -0,0 +1,142 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + LogSettings, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_diagnostic_logging_enabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +PREFIX = f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def build_setting(*logs: LogSettings) -> DiagnosticSetting: + return DiagnosticSetting( + id=f"{ACCOUNT_ID}/providers/microsoft.insights/diagnosticSettings/diag1", + storage_account_id=None, + storage_account_name=None, + logs=list(logs), + name="diag1", + workspace_id="/subscriptions/x/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/law1", + ) + + +def build_account(settings) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + monitor_diagnostic_settings=settings, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_diagnostic_logging_enabled.aiservices_account_diagnostic_logging_enabled import ( + aiservices_account_diagnostic_logging_enabled, + ) + + return aiservices_account_diagnostic_logging_enabled().execute() + + +def single(settings): + return run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(settings)}}) + + +class Test_aiservices_account_diagnostic_logging_enabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_settings_unreadable_is_manual(self): + result = single(None) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"{PREFIX} diagnostic settings could not be read; grant Microsoft.Insights/diagnosticSettings/read (included in Reader) to evaluate audit logging." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_no_diagnostic_settings(self): + result = single([]) + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"{PREFIX} has no diagnostic setting that sends audit logs." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_audit_category_disabled(self): + result = single([build_setting(LogSettings("Audit", None, False))]) + assert result[0].status == "FAIL" + + def test_only_request_response_category(self): + result = single([build_setting(LogSettings("RequestResponse", None, True))]) + assert result[0].status == "FAIL" + + def test_audit_category_enabled(self): + result = single( + [ + build_setting( + LogSettings("RequestResponse", None, True), + LogSettings("Audit", None, True), + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} has a diagnostic setting that sends audit logs." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_audit_category_group_enabled(self): + result = single([build_setting(LogSettings(None, "audit", True))]) + assert result[0].status == "PASS" + + def test_all_logs_category_group_enabled(self): + result = single([build_setting(LogSettings(None, "allLogs", True))]) + assert result[0].status == "PASS" + + def test_category_group_case_insensitive(self): + result = single([build_setting(LogSettings(None, "alllogs", True))]) + assert result[0].status == "PASS" + + def test_second_setting_sends_audit_logs(self): + result = single( + [ + build_setting(), + build_setting(LogSettings(None, "audit", True)), + ] + ) + assert result[0].status == "PASS" diff --git a/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py b/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..f4118656e4 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py @@ -0,0 +1,123 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_encrypted_with_cmk" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" + + +def build_account(key_source: str, key_name=None) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source=key_source, + encryption_key_name=key_name, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_encrypted_with_cmk.aiservices_account_encrypted_with_cmk import ( + aiservices_account_encrypted_with_cmk, + ) + + return aiservices_account_encrypted_with_cmk().execute() + + +class Test_aiservices_account_encrypted_with_cmk: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_microsoft_managed_key(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.CognitiveServices") + } + } + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with a Microsoft-managed key." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_customer_managed_key(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.KeyVault", "cmk1") + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with customer-managed key cmk1." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_key_vault_source_without_key_name(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.KeyVault", None) + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with a customer-managed key from Azure Key Vault." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_key_vault_source_case_insensitive(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.Keyvault", "cmk1") + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with customer-managed key cmk1." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py new file mode 100644 index 0000000000..413b171109 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_local_auth_disabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/foundry1" + + +def build_account(disable_local_auth: bool) -> Account: + return Account( + id=ACCOUNT_ID, + name="foundry1", + location="swedencentral", + kind="AIServices", + public_network_access=False, + disable_local_auth=disable_local_auth, + encryption_key_source="Microsoft.CognitiveServices", + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_local_auth_disabled.aiservices_account_local_auth_disabled import ( + aiservices_account_local_auth_disabled, + ) + + return aiservices_account_local_auth_disabled().execute() + + +class Test_aiservices_account_local_auth_disabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_local_auth_enabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account foundry1 (kind AIServices) from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows local (API key) authentication." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "foundry1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_local_auth_disabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True)}}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account foundry1 (kind AIServices) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has local (API key) authentication disabled and requires Microsoft Entra ID." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "foundry1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled_test.py new file mode 100644 index 0000000000..d39bc69730 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_managed_identity_enabled/aiservices_account_managed_identity_enabled_test.py @@ -0,0 +1,99 @@ +from typing import Optional +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_managed_identity_enabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +PREFIX = f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def build_account(identity_type: Optional[str]) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + identity_type=identity_type, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_managed_identity_enabled.aiservices_account_managed_identity_enabled import ( + aiservices_account_managed_identity_enabled, + ) + + return aiservices_account_managed_identity_enabled().execute() + + +class Test_aiservices_account_managed_identity_enabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_no_identity(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(None)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == f"{PREFIX} has no managed identity." + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_identity_type_none_string(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account("None")}}) + assert result[0].status == "FAIL" + assert result[0].status_extended == f"{PREFIX} has no managed identity." + + def test_identity_type_none_string_lowercase(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account("none")}}) + assert result[0].status == "FAIL" + + def test_system_assigned_identity(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account("SystemAssigned")}} + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} has a managed identity (SystemAssigned)." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_system_and_user_assigned_identity(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("SystemAssigned, UserAssigned") + } + } + ) + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} has a managed identity (SystemAssigned, UserAssigned)." + ) diff --git a/tests/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny_test.py b/tests/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny_test.py new file mode 100644 index 0000000000..ca3baa4a9b --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_network_acls_default_action_deny/aiservices_account_network_acls_default_action_deny_test.py @@ -0,0 +1,100 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_network_acls_default_action_deny" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +PREFIX = f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def build_account(public_network_access: bool, default_action: str) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=public_network_access, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + network_acls_default_action=default_action, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_network_acls_default_action_deny.aiservices_account_network_acls_default_action_deny import ( + aiservices_account_network_acls_default_action_deny, + ) + + return aiservices_account_network_acls_default_action_deny().execute() + + +class Test_aiservices_account_network_acls_default_action_deny: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_public_access_from_all_networks(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True, "Allow")}} + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"{PREFIX} allows public network access from all networks." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_default_action_deny(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True, "Deny")}} + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} restricts public network access to selected networks." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_public_network_access_disabled(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False, "Allow")}} + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended == f"{PREFIX} has public network access disabled." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_default_action_deny_case_insensitive(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True, "deny")}} + ) + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted_test.py b/tests/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted_test.py new file mode 100644 index 0000000000..e9be286cb2 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_outbound_network_restricted/aiservices_account_outbound_network_restricted_test.py @@ -0,0 +1,76 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_outbound_network_restricted" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +PREFIX = f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def build_account(restrict_outbound_network_access: bool) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + restrict_outbound_network_access=restrict_outbound_network_access, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_outbound_network_restricted.aiservices_account_outbound_network_restricted import ( + aiservices_account_outbound_network_restricted, + ) + + return aiservices_account_outbound_network_restricted().execute() + + +class Test_aiservices_account_outbound_network_restricted: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_outbound_unrestricted(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"{PREFIX} allows unrestricted outbound network access." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_outbound_restricted(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True)}}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} restricts outbound network access to an allowed FQDN list." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..199375e9f0 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py @@ -0,0 +1,78 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_public_network_access_disabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" + + +def build_account(public_network_access: bool) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=public_network_access, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_public_network_access_disabled.aiservices_account_public_network_access_disabled import ( + aiservices_account_public_network_access_disabled, + ) + + return aiservices_account_public_network_access_disabled().execute() + + +class Test_aiservices_account_public_network_access_disabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_no_accounts_in_subscription(self): + assert run_check({AZURE_SUBSCRIPTION_ID: {}}) == [] + + def test_public_network_access_enabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + def test_public_network_access_disabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False)}}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint_test.py b/tests/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint_test.py new file mode 100644 index 0000000000..ebaec879d9 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_uses_private_endpoint/aiservices_account_uses_private_endpoint_test.py @@ -0,0 +1,102 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_uses_private_endpoint" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +PREFIX = f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def build_account(statuses: list[str]) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + private_endpoint_connection_statuses=statuses, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_uses_private_endpoint.aiservices_account_uses_private_endpoint import ( + aiservices_account_uses_private_endpoint, + ) + + return aiservices_account_uses_private_endpoint().execute() + + +class Test_aiservices_account_uses_private_endpoint: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_no_private_endpoints(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account([])}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"{PREFIX} has no approved private endpoint connection." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_only_pending_and_rejected_private_endpoints(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account(["Pending", "Rejected"]) + } + } + ) + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"{PREFIX} has no approved private endpoint connection." + ) + + def test_approved_private_endpoint(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account(["Pending", "Approved"]) + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"{PREFIX} has an approved private endpoint connection." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_approved_status_is_case_insensitive(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(["approved"])}} + ) + assert result[0].status == "PASS" diff --git a/tests/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled_test.py b/tests/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled_test.py new file mode 100644 index 0000000000..40a4aadaeb --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_deployment_content_filter_prompt_shield_enabled/aiservices_deployment_content_filter_prompt_shield_enabled_test.py @@ -0,0 +1,240 @@ +from typing import Optional +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import ( + Account, + ContentFilter, + Deployment, + RaiPolicy, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_deployment_content_filter_prompt_shield_enabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +DEPLOYMENT_ID = f"{ACCOUNT_ID}/deployments/gpt-4o" +PREFIX = f"AI services deployment gpt-4o (model gpt-4o) in account openai1 from subscription {AZURE_SUBSCRIPTION_DISPLAY}" + + +def jailbreak_filter(enabled=True, blocking=True, source="Prompt") -> ContentFilter: + return ContentFilter( + name="Jailbreak", source=source, enabled=enabled, blocking=blocking + ) + + +def build_account( + rai_policy_name: str = "Microsoft.DefaultV2", + rai_policies: Optional[dict] = None, + deployments: Optional[list] = "default", + capabilities: Optional[dict] = None, +) -> Account: + if deployments == "default": + deployments = [ + Deployment( + id=DEPLOYMENT_ID, + name="gpt-4o", + location="eastus", + model_name="gpt-4o", + rai_policy_name=rai_policy_name, + capabilities=( + {"chatCompletion": "true"} if capabilities is None else capabilities + ), + ) + ] + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + deployments=deployments, + rai_policies=rai_policies, + ) + + +def policies(*content_filters, name="Microsoft.DefaultV2") -> dict: + return {name: RaiPolicy(name=name, content_filters=list(content_filters))} + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_deployment_content_filter_prompt_shield_enabled.aiservices_deployment_content_filter_prompt_shield_enabled import ( + aiservices_deployment_content_filter_prompt_shield_enabled, + ) + + return aiservices_deployment_content_filter_prompt_shield_enabled().execute() + + +def single(account): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: account}}) + assert len(result) == 1 + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + return result[0] + + +class Test_aiservices_deployment_content_filter_prompt_shield_enabled: + def test_no_resources(self): + assert run_check({}) == [] + + def test_account_without_deployments(self): + assert ( + run_check( + {AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(deployments=[])}} + ) + == [] + ) + + def test_jailbreak_filter_blocking(self): + finding = single(build_account(rai_policies=policies(jailbreak_filter()))) + assert finding.status == "PASS" + assert ( + finding.status_extended + == f"{PREFIX} uses content filter policy Microsoft.DefaultV2, which blocks jailbreak attempts with Prompt Shields." + ) + assert finding.resource_id == DEPLOYMENT_ID + assert finding.resource_name == "gpt-4o" + assert finding.location == "eastus" + + def test_jailbreak_filter_match_is_case_insensitive(self): + content_filter = ContentFilter( + name="jailbreak", source="prompt", enabled=True, blocking=True + ) + finding = single(build_account(rai_policies=policies(content_filter))) + assert finding.status == "PASS" + + def test_jailbreak_filter_missing(self): + finding = single( + build_account( + rai_policy_name="Microsoft.Default", + rai_policies=policies( + ContentFilter( + name="Hate", source="Prompt", enabled=True, blocking=True + ), + name="Microsoft.Default", + ), + ) + ) + assert finding.status == "FAIL" + assert ( + finding.status_extended + == f"{PREFIX} uses content filter policy Microsoft.Default, which does not block jailbreak attempts with Prompt Shields." + ) + assert finding.resource_id == DEPLOYMENT_ID + + def test_jailbreak_filter_disabled(self): + finding = single( + build_account(rai_policies=policies(jailbreak_filter(enabled=False))) + ) + assert finding.status == "FAIL" + + def test_jailbreak_filter_annotate_only(self): + finding = single( + build_account(rai_policies=policies(jailbreak_filter(blocking=False))) + ) + assert finding.status == "FAIL" + + def test_jailbreak_filter_on_completion_only(self): + finding = single( + build_account(rai_policies=policies(jailbreak_filter(source="Completion"))) + ) + assert finding.status == "FAIL" + + def test_policy_name_match_is_case_insensitive(self): + finding = single( + build_account( + rai_policy_name="microsoft.defaultv2", + rai_policies=policies(jailbreak_filter()), + ) + ) + assert finding.status == "PASS" + + def test_embeddings_deployment_is_skipped(self): + account = build_account( + rai_policy_name="", + rai_policies=policies(jailbreak_filter()), + capabilities={"embeddings": "true", "area": "EUR"}, + ) + assert run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: account}}) == [] + + def test_unrecognized_capabilities_is_evaluated(self): + finding = single( + build_account( + rai_policies=policies(jailbreak_filter()), + capabilities={"realtime": "true", "area": "EUR"}, + ) + ) + assert finding.status == "PASS" + + def test_embeddings_with_text_capability_is_evaluated(self): + finding = single( + build_account( + rai_policies=policies(jailbreak_filter()), + capabilities={"embeddings": "true", "chatCompletion": "true"}, + ) + ) + assert finding.status == "PASS" + + def test_missing_capabilities_is_evaluated(self): + finding = single( + build_account(rai_policies=policies(jailbreak_filter()), capabilities={}) + ) + assert finding.status == "PASS" + + def test_default_policy_is_manual(self): + finding = single( + build_account(rai_policy_name="", rai_policies=policies(jailbreak_filter())) + ) + assert finding.status == "MANUAL" + assert ( + finding.status_extended + == f"{PREFIX} uses the account default content filter policy, which Azure does not identify. Confirm that it blocks jailbreak attempts with Prompt Shields." + ) + assert finding.resource_id == DEPLOYMENT_ID + + def test_policies_unreadable_is_manual(self): + finding = single(build_account(rai_policies=None)) + assert finding.status == "MANUAL" + assert ( + finding.status_extended + == f"{PREFIX} uses content filter policy Microsoft.DefaultV2, which could not be read." + ) + + def test_policy_not_found_is_manual(self): + finding = single( + build_account( + rai_policy_name="custom-policy", + rai_policies=policies(jailbreak_filter()), + ) + ) + assert finding.status == "MANUAL" + assert ( + finding.status_extended + == f"{PREFIX} uses content filter policy custom-policy, which was not found on the account." + ) + + def test_deployments_unreadable_is_manual(self): + finding = single(build_account(deployments=None, rai_policies={})) + assert finding.status == "MANUAL" + assert ( + finding.status_extended + == f"Deployments of AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} could not be read." + ) + assert finding.resource_id == ACCOUNT_ID + assert finding.resource_name == "openai1" diff --git a/tests/providers/azure/services/aiservices/aiservices_service_test.py b/tests/providers/azure/services/aiservices/aiservices_service_test.py new file mode 100644 index 0000000000..6907b34c87 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_service_test.py @@ -0,0 +1,507 @@ +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.azure.services.aiservices.aiservices_service import ( + Account, + AIServices, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, + set_mocked_azure_provider, +) + +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +SERVICE_PATH = "prowler.providers.azure.services.aiservices.aiservices_service" +MONITOR_CLIENT_MODULE = "prowler.providers.azure.services.monitor.monitor_client" + + +@pytest.fixture(autouse=True) +def monitor_client(): + """Replace the Monitor client module so no real client is built.""" + client = MagicMock() + client.clients = {AZURE_SUBSCRIPTION_ID: MagicMock()} + client.diagnostic_settings_with_uri.return_value = [] + with patch.dict( + sys.modules, {MONITOR_CLIENT_MODULE: MagicMock(monitor_client=client)} + ): + yield client + + +def mock_get_accounts(_): + return { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=True, + disable_local_auth=False, + encryption_key_source="Microsoft.CognitiveServices", + encryption_key_name=None, + ) + } + } + + +def build_sdk_account( + public_network_access="Disabled", + disable_local_auth=True, + encryption=None, + properties_none=False, + identity=None, + private_endpoint_connections=None, + restrict_outbound_network_access=None, + network_acls=None, +): + account = MagicMock() + account.id = ACCOUNT_ID + account.name = "openai1" + account.location = "eastus" + account.kind = "OpenAI" + account.identity = identity + if properties_none: + account.properties = None + return account + account.properties.public_network_access = public_network_access + account.properties.disable_local_auth = disable_local_auth + account.properties.encryption = encryption + account.properties.private_endpoint_connections = private_endpoint_connections + account.properties.restrict_outbound_network_access = ( + restrict_outbound_network_access + ) + account.properties.network_acls = network_acls + return account + + +def build_sdk_private_endpoint_connection(status): + connection = MagicMock() + connection.properties.private_link_service_connection_state.status = status + return connection + + +def build_sdk_deployment( + name="gpt-4o", rai_policy_name=None, model_name="gpt-4o", capabilities=None +): + deployment = MagicMock() + deployment.id = f"{ACCOUNT_ID}/deployments/{name}" + deployment.name = name + deployment.properties.rai_policy_name = rai_policy_name + deployment.properties.model.name = model_name + deployment.properties.capabilities = capabilities + return deployment + + +def build_sdk_rai_policy(name, content_filters): + policy = MagicMock() + policy.name = name + policy.properties.content_filters = content_filters + return policy + + +def build_sdk_content_filter(name, source, enabled=True, blocking=True): + content_filter = MagicMock() + content_filter.name = name + content_filter.source = source + content_filter.enabled = enabled + content_filter.blocking = blocking + return content_filter + + +def build_service(mock_client, resource_groups=None): + with patch(f"{SERVICE_PATH}.AIServices._get_accounts", return_value={}): + aiservices = AIServices(set_mocked_azure_provider()) + aiservices.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aiservices.resource_groups = resource_groups + return aiservices + + +@patch(f"{SERVICE_PATH}.AIServices._get_accounts", new=mock_get_accounts) +class Test_AIServices_Service: + def test_get_client(self): + aiservices = AIServices(set_mocked_azure_provider()) + assert ( + aiservices.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__ + == "CognitiveServicesManagementClient" + ) + + def test_accounts_populated(self): + aiservices = AIServices(set_mocked_azure_provider()) + account = aiservices.accounts[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + assert account.name == "openai1" + assert account.kind == "OpenAI" + + +class Test_AIServices_get_accounts: + def test_no_resource_groups_lists_subscription(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + mock_client.accounts.list.assert_called_once() + mock_client.accounts.list_by_resource_group.assert_not_called() + account = result[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + assert account.public_network_access is False + assert account.disable_local_auth is True + assert account.encryption_key_source == "Microsoft.CognitiveServices" + assert account.encryption_key_name is None + + def test_single_resource_group(self): + mock_client = MagicMock() + mock_client.accounts.list_by_resource_group.return_value = [build_sdk_account()] + aiservices = build_service( + mock_client, {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + ) + + result = aiservices._get_accounts() + + mock_client.accounts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.accounts.list.assert_not_called() + assert ACCOUNT_ID in result[AZURE_SUBSCRIPTION_ID] + + def test_empty_resource_group_list_skips(self): + mock_client = MagicMock() + aiservices = build_service(mock_client, {AZURE_SUBSCRIPTION_ID: []}) + + result = aiservices._get_accounts() + + mock_client.accounts.list.assert_not_called() + mock_client.accounts.list_by_resource_group.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.accounts.list_by_resource_group.return_value = [] + aiservices = build_service( + mock_client, {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + ) + + aiservices._get_accounts() + + assert mock_client.accounts.list_by_resource_group.call_count == 2 + + def test_public_network_access_none_treated_as_enabled(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(public_network_access=None) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.public_network_access is True + + def test_disable_local_auth_none_treated_as_enabled_local_auth(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(disable_local_auth=None) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.disable_local_auth is False + + def test_customer_managed_key_mapped(self): + encryption = MagicMock() + encryption.key_source = "Microsoft.KeyVault" + encryption.key_vault_properties.key_name = "cmk1" + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(encryption=encryption) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.encryption_key_source == "Microsoft.KeyVault" + assert account.encryption_key_name == "cmk1" + + def test_properties_none_uses_safe_defaults(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(properties_none=True) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.public_network_access is True + assert account.disable_local_auth is False + assert account.encryption_key_source == "Microsoft.CognitiveServices" + assert account.private_endpoint_connection_statuses == [] + assert account.restrict_outbound_network_access is False + assert account.network_acls_default_action == "Allow" + + def test_new_fields_default_when_unset(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.private_endpoint_connection_statuses == [] + assert account.identity_type is None + assert account.restrict_outbound_network_access is False + assert account.network_acls_default_action == "Allow" + + def test_private_endpoint_statuses_mapped(self): + broken = MagicMock() + broken.properties = None + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account( + private_endpoint_connections=[ + build_sdk_private_endpoint_connection("Approved"), + build_sdk_private_endpoint_connection("Pending"), + broken, + ] + ) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.private_endpoint_connection_statuses == ["Approved", "Pending"] + + def test_private_endpoint_status_enum_mapped_to_value(self): + from azure.mgmt.cognitiveservices.models import ( + PrivateEndpointServiceConnectionStatus, + ) + + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account( + private_endpoint_connections=[ + build_sdk_private_endpoint_connection( + PrivateEndpointServiceConnectionStatus.APPROVED + ) + ] + ) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.private_endpoint_connection_statuses == ["Approved"] + + def test_identity_type_mapped(self): + identity = MagicMock() + identity.type = "SystemAssigned" + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account(identity=identity)] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.identity_type == "SystemAssigned" + + def test_restrict_outbound_network_access_mapped(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(restrict_outbound_network_access=True) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.restrict_outbound_network_access is True + + def test_network_acls_default_action_enum_mapped_to_value(self): + from azure.mgmt.cognitiveservices.models import NetworkRuleAction + + network_acls = MagicMock() + network_acls.default_action = NetworkRuleAction.DENY + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(network_acls=network_acls) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.network_acls_default_action == "Deny" + + def test_network_acls_default_action_none_treated_as_allow(self): + network_acls = MagicMock() + network_acls.default_action = None + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(network_acls=network_acls) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.network_acls_default_action == "Allow" + + def test_deployments_and_rai_policies_mapped(self): + from azure.mgmt.cognitiveservices.models import RaiPolicyContentSource + + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + mock_client.deployments.list.return_value = [ + build_sdk_deployment( + rai_policy_name="Microsoft.DefaultV2", + capabilities={"chatCompletion": "true", "area": "EUR"}, + ), + build_sdk_deployment(name="gpt-4o-mini", rai_policy_name=""), + ] + mock_client.rai_policies.list.return_value = [ + build_sdk_rai_policy( + "Microsoft.DefaultV2", + [ + build_sdk_content_filter( + "Jailbreak", RaiPolicyContentSource.PROMPT + ), + build_sdk_content_filter("Hate", "Completion", blocking=False), + ], + ) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + mock_client.deployments.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP, account_name="openai1" + ) + mock_client.rai_policies.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP, account_name="openai1" + ) + assert [d.name for d in account.deployments] == ["gpt-4o", "gpt-4o-mini"] + assert account.deployments[0].id == f"{ACCOUNT_ID}/deployments/gpt-4o" + assert account.deployments[0].location == "eastus" + assert account.deployments[0].model_name == "gpt-4o" + assert account.deployments[0].rai_policy_name == "Microsoft.DefaultV2" + assert account.deployments[1].rai_policy_name == "" + assert account.deployments[0].capabilities == { + "chatCompletion": "true", + "area": "EUR", + } + assert account.deployments[1].capabilities == {} + policy = account.rai_policies["Microsoft.DefaultV2"] + assert policy.content_filters[0].name == "Jailbreak" + assert policy.content_filters[0].source == "Prompt" + assert policy.content_filters[0].enabled is True + assert policy.content_filters[0].blocking is True + assert policy.content_filters[1].blocking is False + + def test_deployment_without_properties_uses_empty_policy_name(self): + deployment = build_sdk_deployment() + deployment.properties = None + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + mock_client.deployments.list.return_value = [deployment] + mock_client.rai_policies.list.return_value = [] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.deployments[0].rai_policy_name == "" + assert account.deployments[0].model_name == "" + assert account.deployments[0].capabilities == {} + assert account.rai_policies == {} + + def test_deployments_failure_is_none(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + mock_client.deployments.list.side_effect = Exception("boom") + mock_client.rai_policies.list.return_value = [] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.deployments is None + assert account.rai_policies == {} + + def test_rai_policies_failure_is_none(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + mock_client.deployments.list.return_value = [build_sdk_deployment()] + mock_client.rai_policies.list.side_effect = Exception("boom") + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert len(account.deployments) == 1 + assert account.rai_policies is None + + def test_list_failure_leaves_subscription_empty(self): + mock_client = MagicMock() + mock_client.accounts.list.side_effect = Exception("boom") + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_one_bad_account_does_not_drop_others(self): + bad = build_sdk_account() + bad.id = None # fails Pydantic validation: id must be str + good = build_sdk_account() + mock_client = MagicMock() + mock_client.accounts.list.return_value = [bad, good] + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + assert list(result[AZURE_SUBSCRIPTION_ID]) == [ACCOUNT_ID] + + def test_diagnostic_settings_fetched_per_account(self, monitor_client): + setting = MagicMock() + monitor_client.diagnostic_settings_with_uri.return_value = [setting] + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + monitor_client.diagnostic_settings_with_uri.assert_called_once_with( + AZURE_SUBSCRIPTION_ID, + ACCOUNT_ID, + monitor_client.clients[AZURE_SUBSCRIPTION_ID], + raise_errors=True, + ) + assert account.monitor_diagnostic_settings == [setting] + + def test_diagnostic_settings_none_configured_is_empty_list(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.monitor_diagnostic_settings == [] + + def test_diagnostic_settings_failure_is_none(self, monitor_client): + monitor_client.diagnostic_settings_with_uri.side_effect = Exception("boom") + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + assert ( + result[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID].monitor_diagnostic_settings + is None + ) + + def test_diagnostic_settings_no_monitor_client_for_subscription_is_none( + self, monitor_client + ): + monitor_client.clients = {} + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.monitor_diagnostic_settings is None diff --git a/tests/providers/azure/services/monitor/monitor_service_test.py b/tests/providers/azure/services/monitor/monitor_service_test.py index 2bc7904c51..40246cc86f 100644 --- a/tests/providers/azure/services/monitor/monitor_service_test.py +++ b/tests/providers/azure/services/monitor/monitor_service_test.py @@ -1,6 +1,7 @@ from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import pytest from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from prowler.providers.azure.services.monitor.lib.monitor_alerts import check_alert_rule @@ -112,6 +113,27 @@ class Test_Monitor_Service: == "/subscriptions/1234a5-123a-123a-123a-1234567890ab/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storageaccountname" ) + def test_diagnostic_settings_with_uri_error_returns_empty_by_default(self): + monitor = Monitor(set_mocked_azure_provider()) + client = MagicMock() + client.diagnostic_settings.list.side_effect = Exception("boom") + + result = monitor.diagnostic_settings_with_uri( + AZURE_SUBSCRIPTION_ID, "uri", client + ) + + assert result == [] + + def test_diagnostic_settings_with_uri_error_raises_when_requested(self): + monitor = Monitor(set_mocked_azure_provider()) + client = MagicMock() + client.diagnostic_settings.list.side_effect = Exception("boom") + + with pytest.raises(Exception, match="boom"): + monitor.diagnostic_settings_with_uri( + AZURE_SUBSCRIPTION_ID, "uri", client, raise_errors=True + ) + def test__monitor_alerts_false__(self): alert_rule = AlertRule( id="id", diff --git a/tests/providers/gcp/services/vertexai/vertexai_service_test.py b/tests/providers/gcp/services/vertexai/vertexai_service_test.py new file mode 100644 index 0000000000..e2e419fd4a --- /dev/null +++ b/tests/providers/gcp/services/vertexai/vertexai_service_test.py @@ -0,0 +1,158 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.vertexai.vertexai_service import VertexAI +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_ZONE = "us-central1-a" +_INSTANCE_NAME = "my-workbench" +_INSTANCE_ID = f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{_INSTANCE_NAME}" +_NETWORK = f"projects/{GCP_PROJECT_ID}/global/networks/default" + + +def _make_notebooks_client(instances_list=None, list_error=None): + """Return a mock Notebooks v2 API client for Workbench instances.""" + client = MagicMock() + instances = client.projects().locations().instances() + if list_error: + instances.list().execute.side_effect = list_error + else: + instances.list().execute.return_value = {"instances": instances_list or []} + instances.list_next.return_value = None + return client + + +def _build_service(client): + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=lambda *_: client, + ), + ): + return VertexAI(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + +class TestVertexAIService: + def test_service_uses_notebooks_v2_api(self): + vertexai_client = _build_service(_make_notebooks_client()) + + assert vertexai_client.service == "notebooks" + assert vertexai_client.api_version == "v2" + assert vertexai_client.project_ids == [GCP_PROJECT_ID] + assert vertexai_client.workbench_instances == [] + + def test_lists_instances_across_all_locations(self): + client = _make_notebooks_client() + _build_service(client) + + client.projects().locations().instances().list.assert_called_with( + parent=f"projects/{GCP_PROJECT_ID}/locations/-" + ) + + def test_get_workbench_instance_hardened(self): + vertexai_client = _build_service( + _make_notebooks_client( + instances_list=[ + { + "name": _INSTANCE_ID, + "state": "ACTIVE", + "gceSetup": { + "disablePublicIp": True, + "metadata": {"notebook-disable-root": "true"}, + "networkInterfaces": [{"network": _NETWORK}], + }, + } + ] + ) + ) + + assert len(vertexai_client.workbench_instances) == 1 + instance = vertexai_client.workbench_instances[0] + assert instance.id == _INSTANCE_ID + assert instance.name == _INSTANCE_NAME + assert instance.project_id == GCP_PROJECT_ID + assert instance.location == _ZONE + assert instance.state == "ACTIVE" + assert instance.public_ip_disabled is True + assert instance.root_access_disabled is True + assert instance.network == _NETWORK + + def test_get_workbench_instance_defaults(self): + vertexai_client = _build_service( + _make_notebooks_client( + instances_list=[{"name": _INSTANCE_ID, "gceSetup": {}}] + ) + ) + + instance = vertexai_client.workbench_instances[0] + assert instance.state == "STATE_UNSPECIFIED" + assert instance.public_ip_disabled is False + assert instance.root_access_disabled is False + assert instance.network is None + + def test_root_access_metadata_is_case_insensitive(self): + vertexai_client = _build_service( + _make_notebooks_client( + instances_list=[ + { + "name": _INSTANCE_ID, + "gceSetup": {"metadata": {"notebook-disable-root": "TRUE"}}, + } + ] + ) + ) + + assert vertexai_client.workbench_instances[0].root_access_disabled is True + + def test_root_access_metadata_false(self): + vertexai_client = _build_service( + _make_notebooks_client( + instances_list=[ + { + "name": _INSTANCE_ID, + "gceSetup": {"metadata": {"notebook-disable-root": "false"}}, + } + ] + ) + ) + + assert vertexai_client.workbench_instances[0].root_access_disabled is False + + def test_unreachable_locations_return_no_instances(self): + client = _make_notebooks_client() + client.projects().locations().instances().list().execute.return_value = { + "unreachable": ["us-west1-a", "us-central1-b"] + } + + vertexai_client = _build_service(client) + + assert vertexai_client.workbench_instances == [] + + def test_unreachable_on_later_page_keeps_listed_instances(self): + client = _make_notebooks_client( + instances_list=[{"name": _INSTANCE_ID, "gceSetup": {}}] + ) + second_page = MagicMock() + second_page.execute.return_value = {"unreachable": ["us-west1-a"]} + client.projects().locations().instances().list_next.side_effect = [ + second_page, + None, + ] + + vertexai_client = _build_service(client) + + assert [i.name for i in vertexai_client.workbench_instances] == [_INSTANCE_NAME] + + def test_list_error_returns_no_instances(self): + vertexai_client = _build_service( + _make_notebooks_client(list_error=Exception("permission denied")) + ) + + assert vertexai_client.workbench_instances == [] diff --git a/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled_test.py b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled_test.py new file mode 100644 index 0000000000..0e66089a3b --- /dev/null +++ b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled_test.py @@ -0,0 +1,104 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + +_ZONE = "us-central1-a" +_CHECK_PATH = ( + "prowler.providers.gcp.services.vertexai." + "vertexai_workbench_instance_public_ip_disabled." + "vertexai_workbench_instance_public_ip_disabled" +) +_CLIENT_PATH = f"{_CHECK_PATH}.vertexai_client" + + +def _instance_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{name}" + + +def _mock_vertexai_client(instances=None): + vertexai_client = mock.MagicMock() + vertexai_client.workbench_instances = instances or [] + return vertexai_client + + +def _run_check(vertexai_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch(_CLIENT_PATH, new=vertexai_client), + ): + from prowler.providers.gcp.services.vertexai.vertexai_workbench_instance_public_ip_disabled.vertexai_workbench_instance_public_ip_disabled import ( + vertexai_workbench_instance_public_ip_disabled, + ) + + return vertexai_workbench_instance_public_ip_disabled().execute() + + +def _workbench_instance(name, public_ip_disabled, state="ACTIVE"): + from prowler.providers.gcp.services.vertexai.vertexai_service import ( + WorkbenchInstance, + ) + + return WorkbenchInstance( + id=_instance_id(name), + name=name, + project_id=GCP_PROJECT_ID, + location=_ZONE, + state=state, + public_ip_disabled=public_ip_disabled, + ) + + +class Test_vertexai_workbench_instance_public_ip_disabled: + def test_no_instances(self): + result = _run_check(_mock_vertexai_client()) + + assert len(result) == 0 + + def test_instance_public_ip_disabled(self): + result = _run_check( + _mock_vertexai_client( + instances=[_workbench_instance("private-nb", public_ip_disabled=True)] + ) + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Vertex AI Workbench instance private-nb does not have a public IP address." + ) + assert result[0].resource_id == _instance_id("private-nb") + assert result[0].resource_name == "private-nb" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == _ZONE + + def test_instance_public_ip_enabled(self): + result = _run_check( + _mock_vertexai_client( + instances=[_workbench_instance("public-nb", public_ip_disabled=False)] + ) + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Vertex AI Workbench instance public-nb has a public IP address assigned, allowing direct internet access." + ) + assert result[0].resource_id == _instance_id("public-nb") + + def test_deleted_instance_is_skipped(self): + result = _run_check( + _mock_vertexai_client( + instances=[ + _workbench_instance( + "gone-nb", public_ip_disabled=False, state="DELETED" + ) + ] + ) + ) + + assert len(result) == 0 diff --git a/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled_test.py b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled_test.py new file mode 100644 index 0000000000..45df0ad76b --- /dev/null +++ b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled_test.py @@ -0,0 +1,104 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + +_ZONE = "us-central1-a" +_CHECK_PATH = ( + "prowler.providers.gcp.services.vertexai." + "vertexai_workbench_instance_root_access_disabled." + "vertexai_workbench_instance_root_access_disabled" +) +_CLIENT_PATH = f"{_CHECK_PATH}.vertexai_client" + + +def _instance_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{name}" + + +def _mock_vertexai_client(instances=None): + vertexai_client = mock.MagicMock() + vertexai_client.workbench_instances = instances or [] + return vertexai_client + + +def _run_check(vertexai_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch(_CLIENT_PATH, new=vertexai_client), + ): + from prowler.providers.gcp.services.vertexai.vertexai_workbench_instance_root_access_disabled.vertexai_workbench_instance_root_access_disabled import ( + vertexai_workbench_instance_root_access_disabled, + ) + + return vertexai_workbench_instance_root_access_disabled().execute() + + +def _workbench_instance(name, root_access_disabled, state="ACTIVE"): + from prowler.providers.gcp.services.vertexai.vertexai_service import ( + WorkbenchInstance, + ) + + return WorkbenchInstance( + id=_instance_id(name), + name=name, + project_id=GCP_PROJECT_ID, + location=_ZONE, + state=state, + root_access_disabled=root_access_disabled, + ) + + +class Test_vertexai_workbench_instance_root_access_disabled: + def test_no_instances(self): + result = _run_check(_mock_vertexai_client()) + + assert len(result) == 0 + + def test_instance_root_access_disabled(self): + result = _run_check( + _mock_vertexai_client( + instances=[_workbench_instance("locked-nb", root_access_disabled=True)] + ) + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Vertex AI Workbench instance locked-nb has root access disabled." + ) + assert result[0].resource_id == _instance_id("locked-nb") + assert result[0].resource_name == "locked-nb" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == _ZONE + + def test_instance_root_access_enabled(self): + result = _run_check( + _mock_vertexai_client( + instances=[_workbench_instance("sudo-nb", root_access_disabled=False)] + ) + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Vertex AI Workbench instance sudo-nb has root access enabled." + ) + assert result[0].resource_id == _instance_id("sudo-nb") + + def test_deleted_instance_is_skipped(self): + result = _run_check( + _mock_vertexai_client( + instances=[ + _workbench_instance( + "gone-nb", root_access_disabled=False, state="DELETED" + ) + ] + ) + ) + + assert len(result) == 0 diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index fe51aacb8c..6d3622dc72 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from prowler.lib.check.models import CheckReportIAC +from prowler.lib.network.ssrf import OutboundURLNotAllowedError from prowler.providers.iac.exceptions.exceptions import ( IacRepositoryCloneError, IacScanError, @@ -784,7 +785,9 @@ class TestIacProvider: url = "https://github.com/user/repo.git" with mock.patch.object(provider, "_detect_branch_name", return_value="main"): temp_dir, branch_name = provider._clone_repository(url) - mock_clone.assert_called_with(url, "/tmp/fake-dir", depth=1) + mock_clone.assert_called_with( + url, "/tmp/fake-dir", depth=1, config=mock.ANY, pool_manager=mock.ANY + ) assert temp_dir == "/tmp/fake-dir" assert branch_name == "main" @@ -798,7 +801,13 @@ class TestIacProvider: url, github_username="user", personal_access_token="token123" ) expected_url = "https://user:token123@github.com/user/repo.git" - mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1) + mock_clone.assert_called_with( + expected_url, + "/tmp/fake-dir", + depth=1, + config=mock.ANY, + pool_manager=mock.ANY, + ) assert temp_dir == "/tmp/fake-dir" assert branch_name == "develop" @@ -812,10 +821,34 @@ class TestIacProvider: url, oauth_app_token="oauth456" ) expected_url = "https://oauth2:oauth456@github.com/user/repo.git" - mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1) + mock_clone.assert_called_with( + expected_url, + "/tmp/fake-dir", + depth=1, + config=mock.ANY, + pool_manager=mock.ANY, + ) assert temp_dir == "/tmp/fake-dir" assert branch_name == "master" + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") + @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") + def test_clone_repository_validates_redirect_destinations( + self, _mock_mkdtemp, mock_clone + ): + """dulwich adopts a redirect's destination, so the clone needs the guarded manager.""" + provider = IacProvider() + with ( + mock.patch.object(provider, "_detect_branch_name", return_value="main"), + mock.patch( + "prowler.providers.iac.iac_provider.guarded_pool_manager", + return_value="the-guarded-manager", + ), + ): + provider._clone_repository("https://github.com/user/repo.git") + + assert mock_clone.call_args.kwargs["pool_manager"] == "the-guarded-manager" + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") def test_clone_repository_failure_raises(self, _mock_mkdtemp, mock_clone): @@ -828,13 +861,19 @@ class TestIacProvider: lets the API report the failure as a normal task error instead of a `SystemExit` escaping the Celery worker. """ - mock_clone.side_effect = Exception("repository not found") + mock_clone.side_effect = Exception( + "https://x-access-token:SENTINEL_TOKEN@github.com/user/repo.git refused" + ) with pytest.raises(IacRepositoryCloneError) as exc_info: IacProvider(scan_repository_url="https://github.com/user/repo.git") - assert "repository not found" in str(exc_info.value) + # ProwlerException formats original_exception into its str(), and the API + # returns that, so the authenticated URL must not reach it + assert "SENTINEL_TOKEN" not in str(exc_info.value) + assert "Exception" in str(exc_info.value) assert exc_info.value.code == 21000 + assert isinstance(exc_info.value.__cause__, Exception) def test_detect_branch_name_main(self): """Test detecting 'main' branch from .git/HEAD""" @@ -878,3 +917,95 @@ class TestIacProvider: # Pass a non-existent directory branch_name = provider._detect_branch_name("/non/existent/path") assert branch_name == "main" + + def test_test_connection_rejects_loopback_url(self): + with patch("prowler.providers.iac.iac_provider.ls_remote") as mock_ls_remote: + connection = IacProvider.test_connection( + scan_repository_url="https://127.0.0.1/user/repo.git" + ) + + assert connection.is_connected is False + assert connection.error == "Repository URL is not an allowed destination." + mock_ls_remote.assert_not_called() + + def test_test_connection_rejects_private_range_url(self): + with patch("prowler.providers.iac.iac_provider.ls_remote") as mock_ls_remote: + connection = IacProvider.test_connection( + scan_repository_url="https://10.0.0.1/user/repo.git" + ) + + assert connection.is_connected is False + assert connection.error == "Repository URL is not an allowed destination." + mock_ls_remote.assert_not_called() + + def test_clone_repository_rejects_a_non_public_url(self): + provider = IacProvider.__new__(IacProvider) + with patch("prowler.providers.iac.iac_provider.porcelain.clone") as mock_clone: + with pytest.raises(OutboundURLNotAllowedError): + provider._clone_repository("https://169.254.169.254/org/repo.git") + + mock_clone.assert_not_called() + + def test_clone_repository_rejects_shared_address_space(self): + provider = IacProvider.__new__(IacProvider) + with patch("prowler.providers.iac.iac_provider.porcelain.clone") as mock_clone: + with pytest.raises(OutboundURLNotAllowedError): + provider._clone_repository("https://100.100.100.200/org/repo.git") + + mock_clone.assert_not_called() + + def test_clone_repository_does_not_validate_the_token_bearing_url(self): + provider = IacProvider.__new__(IacProvider) + with patch( + "prowler.lib.network.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("140.82.121.4", 0))], + ) as mock_getaddrinfo: + with patch("prowler.providers.iac.iac_provider.porcelain.clone"): + with patch.object( + IacProvider, "_detect_branch_name", return_value="main" + ): + provider._clone_repository( + "https://github.com/org/repo.git", + github_username="user", + personal_access_token="token", + ) + + assert mock_getaddrinfo.call_args[0][0] == "github.com" + + def test_test_connection_allows_public_url(self): + with ( + patch( + "prowler.lib.network.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("140.82.121.4", 0))], + ) as mock_getaddrinfo, + patch("prowler.providers.iac.iac_provider.ls_remote") as mock_ls_remote, + ): + connection = IacProvider.test_connection( + scan_repository_url="https://github.com/user/repo.git" + ) + + assert connection.is_connected is True + mock_getaddrinfo.assert_called_once_with("github.com", None) + mock_ls_remote.assert_called_once_with("https://github.com/user/repo.git") + + def test_test_connection_does_not_echo_raw_error(self): + with ( + patch( + "prowler.lib.network.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("140.82.121.4", 0))], + ), + patch( + "prowler.providers.iac.iac_provider.ls_remote", + side_effect=Exception( + "https://x-access-token:SENTINEL_TOKEN@github.com/user/repo.git refused" + ), + ), + ): + connection = IacProvider.test_connection( + scan_repository_url="https://github.com/user/repo.git", + oauth_app_token="SENTINEL_TOKEN", + ) + + assert connection.is_connected is False + assert connection.error == "Failed to connect to repository." + assert "SENTINEL_TOKEN" not in connection.error diff --git a/tests/providers/iac/lib/git_transport_test.py b/tests/providers/iac/lib/git_transport_test.py new file mode 100644 index 0000000000..00851b8fec --- /dev/null +++ b/tests/providers/iac/lib/git_transport_test.py @@ -0,0 +1,168 @@ +import http.server +import socket +import threading +from unittest import mock + +import pytest +from dulwich.config import ConfigDict + +from prowler.lib.network.ssrf import ( + ALLOWED_PRIVATE_NETWORKS_ENV, + OutboundURLNotAllowedError, + validate_outbound_url, +) +from prowler.providers.iac.lib import git_transport +from prowler.providers.iac.lib.git_transport import ls_remote + +LINK_LOCAL_TARGET = "http://169.254.169.254/evil/info/refs?service=git-upload-pack" +# Kept apart from the URLs so the fixtures do not read as leaked credentials. +USERINFO = "user:a-token" + + +def _resolves_to(*addresses): + return mock.patch.object( + socket, + "getaddrinfo", + return_value=[(2, 1, 6, "", (address, 0)) for address in addresses], + ) + + +class _Recorder: + def __init__(self): + self.requests = [] + self.redirect_to = None + + +def _server(recorder): + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + recorder.requests.append((self.path, self.headers.get("Authorization"))) + if recorder.redirect_to: + self.send_response(302) + self.send_header("Location", recorder.redirect_to) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *_args): + pass + + httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd + + +@pytest.fixture +def origin(): + recorder = _Recorder() + httpd = _server(recorder) + recorder.port = httpd.server_address[1] + recorder.url = f"http://127.0.0.1:{recorder.port}" + yield recorder + httpd.shutdown() + + +@pytest.fixture +def final(): + recorder = _Recorder() + httpd = _server(recorder) + recorder.port = httpd.server_address[1] + recorder.url = f"http://127.0.0.1:{recorder.port}" + yield recorder + httpd.shutdown() + + +@pytest.fixture(autouse=True) +def allow_loopback(monkeypatch): + # the servers run on loopback, so the guard has to be told to allow it; + # the redirect targets under test are judged by the same allowlist + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "127.0.0.1/32") + + +class TestLsRemote: + def test_rejects_a_redirect_to_a_non_public_address(self, origin): + origin.redirect_to = LINK_LOCAL_TARGET + + with pytest.raises(OutboundURLNotAllowedError): + ls_remote(f"{origin.url}/repo.git") + + assert len(origin.requests) == 1 + + def test_follows_a_redirect_to_an_allowed_address(self, origin, final): + origin.redirect_to = f"{final.url}/moved/info/refs?service=git-upload-pack" + + # the final server answers 404, so dulwich reports not a repository + # rather than a rejection: the hop was allowed and followed + with pytest.raises(Exception) as outcome: + ls_remote(f"{origin.url}/repo.git") + + assert not isinstance(outcome.value, OutboundURLNotAllowedError) + assert len(final.requests) == 1 + + def test_keeps_sending_the_url_credentials(self, origin): + """A supplied pool manager must not cost dulwich its userinfo authentication.""" + with pytest.raises(Exception): + ls_remote(f"http://{USERINFO}@127.0.0.1:{origin.port}/repo.git") + + assert origin.requests[0][1] is not None + + +class TestEffectiveDestination: + def test_rejects_a_private_host_an_insteadof_rule_rewrites_to(self, monkeypatch): + """git rewrites the URL before dulwich picks the transport. + + The supplied host resolves public on purpose, so only the rewrite can + make this fail. + """ + config = ConfigDict() + config.set( + (b"url", b"ssh://10.0.0.5/"), b"insteadOf", b"https://reg.example.com/" + ) + monkeypatch.setattr(git_transport, "git_config", lambda: config) + + with _resolves_to("140.82.121.4"): + with pytest.raises(OutboundURLNotAllowedError, match="non-public"): + git_transport.ls_remote("https://reg.example.com/org/repo.git") + + @pytest.mark.parametrize( + "url,expected", + [ + ( + f"https://{USERINFO}@github.com:8443/o/r.git", + "https://github.com:8443/o/r.git", + ), + # the path stays: an http..* section can be path-specific + (f"https://{USERINFO}@github.com/o/r.git", "https://github.com/o/r.git"), + # urlparse drops the brackets of an IPv6 literal + (f"https://{USERINFO}@[::1]:8443/o/r.git", "https://[::1]:8443/o/r.git"), + # nothing to strip, so nothing is rewritten + ( + "https://[2606:4700:4700::1111]/o/r.git", + "https://[2606:4700:4700::1111]/o/r.git", + ), + ], + ) + def test_strips_only_the_credentials_from_the_proxy_base_url(self, url, expected): + assert git_transport.proxy_base_url(url) == expected + + @pytest.mark.parametrize("scheme", ["http", "https", "ssh", "git+ssh", "git"]) + def test_allows_every_scheme_dulwich_can_transport(self, scheme, monkeypatch): + """A scheme dulwich routes must reach the host check, not be refused on scheme.""" + monkeypatch.setattr(git_transport, "git_config", ConfigDict) + + with _resolves_to("140.82.121.4"): + # the scheme must not be what rejects it; a public host is accepted + validate_outbound_url( + f"{scheme}://host.example/o/r.git", + allowed_schemes=git_transport.ALLOWED_SCHEMES, + ) + + def test_honours_a_no_proxy_bypass(self, monkeypatch, origin): + """Built without base_url, dulwich cannot see no_proxy and uses the proxy.""" + monkeypatch.setenv("http_proxy", "http://127.0.0.1:1") + monkeypatch.setenv("no_proxy", "127.0.0.1") + + with pytest.raises(Exception): + git_transport.ls_remote(f"{origin.url}/repo.git") + + assert len(origin.requests) == 1 diff --git a/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py new file mode 100644 index 0000000000..7663165af1 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py @@ -0,0 +1,783 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import AppRegistration +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_app_registration_redirect_uris_secure: + """Tests for the entra_app_registration_redirect_uris_secure check.""" + + def test_no_app_registrations(self): + """No app registrations in tenant: no findings.""" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 0 + + def test_app_no_redirect_uris(self): + """App with no redirect URIs configured: expected PASS.""" + app_id = str(uuid4()) + app_name = "Clean App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "no redirect URIs configured" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_all_secure_uris(self): + """App with only secure redirect URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Secure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://portal.contoso.com/signin-oidc", + "https://app.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth" + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "all redirect URIs secure" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_non_localhost_uri(self): + """App with an http:// URI on a non-localhost host: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Http App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + assert "portal.contoso.com" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_localhost_allowed(self): + """App with http://localhost: expected PASS (loopback is allowed).""" + app_id = str(uuid4()) + app_name = "Localhost App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://localhost:3000", + "http://127.0.0.1:8080", + "http://[::1]:5000", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + + def test_app_with_azurewebsites_uri(self): + """App with a host on *.azurewebsites.net: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Azure Web App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://legacy-portal.azurewebsites.net/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_bare_azurewebsites_domain(self): + """App with host exactly azurewebsites.net (no subdomain): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Bare Domain App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://azurewebsites.net/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + + def test_app_with_wildcard_uri(self): + """App with a wildcard in the redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://*.contoso.com/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + assert result[0].resource_id == app_id + + def test_app_with_malformed_uri_no_scheme(self): + """App with a malformed URI (no scheme): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Malformed App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "just-a-path/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_malformed_uri_no_host(self): + """App with a malformed URI (scheme but no host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "No Host App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https:///callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + + def test_app_with_custom_scheme_public_client(self): + """App with custom scheme public-client URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Native App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth", + "ms-appx-web://Microsoft.AAD.BrokerPlugin/1234", + "brk-multihub://contoso", + "urn:ietf:wg:oauth:2.0:oob", + "com.company.app://callback", + "msauth.com.contoso.app://auth", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_multiple_insecure_uris(self): + """App with multiple insecure redirect URIs: expected FAIL listing all.""" + app_id = str(uuid4()) + app_name = "Legacy Portal" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + "https://legacy-portal.azurewebsites.net/signin-oidc", + "https://*.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 insecure redirect URI(s)" in result[0].status_extended + assert result[0].resource_name == app_name + + def test_multiple_apps_mixed_results(self): + """Multiple apps: one secure, one insecure.""" + app_id_pass = str(uuid4()) + app_name_pass = "Secure App" + app_id_fail = str(uuid4()) + app_name_fail = "Insecure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id_pass: AppRegistration( + id=app_id_pass, + app_id=str(uuid4()), + name=app_name_pass, + web_redirect_uris=["https://app.contoso.com/callback"], + ), + app_id_fail: AppRegistration( + id=app_id_fail, + app_id=str(uuid4()), + name=app_name_fail, + web_redirect_uris=[ + "http://app.insecure.com/callback", + ], + ), + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 2 + result_pass = next(r for r in result if r.resource_id == app_id_pass) + result_fail = next(r for r in result if r.resource_id == app_id_fail) + assert result_pass.status == "PASS" + assert result_pass.resource_name == app_name_pass + assert result_fail.status == "FAIL" + assert result_fail.resource_name == app_name_fail + + def test_app_with_more_than_five_insecure_uris_truncated(self): + """App with more than 5 insecure URIs: status_extended is truncated.""" + app_id = str(uuid4()) + app_name = "Many URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(7)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7 insecure redirect URI(s)" in result[0].status_extended + assert "(and 2 more)" in result[0].status_extended + + def test_app_with_exactly_five_insecure_uris_no_truncation(self): + """App with exactly 5 insecure URIs: all displayed, no truncation.""" + app_id = str(uuid4()) + app_name = "Five URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(5)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "5 insecure redirect URI(s)" in result[0].status_extended + assert "(and" not in result[0].status_extended + + def test_app_with_wildcard_in_path(self): + """App with a wildcard in the path (not host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard Path App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://contoso.com/*/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + + def test_app_with_insecure_spa_redirect_uri(self): + """App with insecure SPA redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Insecure SPA" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://app.contoso.com:4200", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + + def test_app_resource_name_falls_back_to_app_id(self): + """App with no display name: resource_name falls back to app_id.""" + app_id = str(uuid4()) + app_app_id = str(uuid4()) + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=app_app_id, + name="", + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == app_app_id + assert f"App registration {app_app_id} has" in result[0].status_extended + + def test_check_uri_edge_cases(self): + """Edge cases for the redirect URI evaluation helper.""" + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + _check_uri, + ) + + # Loopback hosts are allowed over plain http. + assert _check_uri("http://localhost:3000/callback") is None + assert _check_uri("http://127.0.0.1:8080/callback") is None + assert _check_uri("http://[::1]:8080/callback") is None + # Wildcards are flagged even in native schemes. + assert _check_uri("com.contoso.app://*") == "contains wildcard" + # Prefixes only exempt the scheme, not any string that starts with them. + assert _check_uri("msalfoo.example.com/callback") == "malformed URI" + assert _check_uri("msal11111111-1111-1111-1111-111111111111://auth") is None + assert _check_uri("urn:ietf:wg:oauth:2.0:oob") is None + # Invalid ports make the URI malformed. + assert ( + _check_uri("https://example.com:notaport/callback") == "malformed URI" + ) + assert _check_uri("https://example.com:99999/callback") == "malformed URI" + # azurewebsites.net explains when the takeover risk applies. + reason = _check_uri("https://contoso.azurewebsites.net/signin-oidc") + assert "subdomain takeover risk if the App Service is removed" in reason diff --git a/tests/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py b/tests/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py new file mode 100644 index 0000000000..26a6329daa --- /dev/null +++ b/tests/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py @@ -0,0 +1,376 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + CrossTenantAccessDefault, + CrossTenantB2BSetting, + CrossTenantTarget, + CrossTenantTargetConfiguration, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted" + + +def _all_users_all_apps_allowed(): + """Return a B2B setting that allows all users and all applications.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[CrossTenantTarget(target="AllUsers", target_type="user")], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _blocked_setting(): + """Return a B2B setting that blocks all users and all applications.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[CrossTenantTarget(target="AllUsers", target_type="user")], + ), + applications=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _specific_groups_allowed(): + """Return a B2B setting that allows only specific groups and all apps.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget( + target="00000000-0000-0000-0000-000000000001", + target_type="user", + ) + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _setting(users_access, users_targets, apps_access, apps_targets): + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type=users_access, + targets=[ + CrossTenantTarget(target=t, target_type="user") for t in users_targets + ], + ), + applications=CrossTenantTargetConfiguration( + access_type=apps_access, + targets=[ + CrossTenantTarget(target=t, target_type="application") + for t in apps_targets + ], + ), + ) + + +class Test_entra_cross_tenant_access_default_outbound_restricted: + def _run(self, policy): + entra_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted import ( + entra_cross_tenant_access_default_outbound_restricted, + ) + + entra_client.cross_tenant_access_default = policy + return entra_cross_tenant_access_default_outbound_restricted().execute() + + def test_no_policy(self): + """MANUAL when the policy could not be retrieved.""" + result = self._run(None) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be read" in result[0].status_extended + + def test_both_blocked_pass(self): + """PASS when both outbound settings are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "restricts outbound access" in result[0].status_extended + + def test_collaboration_outbound_unrestricted_fail(self): + """FAIL when B2B collaboration outbound allows all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + assert "B2B direct connect outbound" not in result[0].status_extended + + def test_direct_connect_outbound_unrestricted_fail(self): + """FAIL when B2B direct connect outbound allows all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_all_users_all_apps_allowed(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B direct connect outbound" in result[0].status_extended + assert "B2B collaboration outbound" not in result[0].status_extended + + def test_both_outbound_unrestricted_fail(self): + """FAIL when both outbound settings allow all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=_all_users_all_apps_allowed(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + assert "B2B direct connect outbound" in result[0].status_extended + + def test_specific_groups_allowed_pass(self): + """PASS when users are scoped to specific groups (not AllUsers).""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_specific_groups_allowed(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_none_b2b_settings_manual(self): + """MANUAL when the outbound settings could not be read.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=None, + b2b_direct_connect_outbound=None, + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_incomplete_setting_with_blocked_setting_manual(self): + """MANUAL when no setting is open but one has no users and groups data.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=None, + applications=_blocked_setting().applications, + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "incomplete" in result[0].status_extended + + def test_setting_without_targets_manual(self): + """MANUAL when an access configuration has no targets.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", targets=[] + ), + applications=_blocked_setting().applications, + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_open_setting_with_incomplete_setting_fail(self): + """FAIL takes precedence when one setting is open and the other incomplete.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=CrossTenantB2BSetting( + users_and_groups=None, applications=None + ), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_open_setting_with_missing_setting_fail(self): + """FAIL takes precedence when one setting is open and the other missing.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=None, + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_users_allowed_all_but_apps_blocked_pass(self): + """PASS when users are allowed for all but applications are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllUsers", target_type="user") + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget( + target="AllApplications", + target_type="application", + ) + ], + ), + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_apps_allowed_all_but_users_blocked_pass(self): + """PASS when applications are allowed for all but users are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget(target="AllUsers", target_type="user") + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget( + target="AllApplications", + target_type="application", + ) + ], + ), + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_resource_metadata(self): + """Verify resource_name and resource_id are set correctly.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].resource_name == "Cross-Tenant Access Default Policy" + assert result[0].resource_id == "crossTenantAccessPolicyConfigurationDefault" + + def test_blocking_one_group_with_all_apps_allowed_fails(self): + """Blocking a single group leaves every other user able to reach every app.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["group-1"], "allowed", ["AllApplications"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_blocking_one_app_with_all_users_allowed_fails(self): + """Blocking a single application leaves every other application reachable.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["AllUsers"], "blocked", ["app-1"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + + def test_all_microsoft_apps_counts_as_open(self): + """AllMicrosoftApps reaches every Microsoft cloud application.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["AllUsers"], "allowed", ["AllMicrosoftApps"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + + def test_blocking_all_microsoft_apps_restricts_applications(self): + """Blocking AllMicrosoftApps closes the applications dimension.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["group-1"], "blocked", ["AllMicrosoftApps"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" + + def test_users_limited_to_group_with_partial_app_block_passes(self): + """Users allowed only for a specific group restrict the setting.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["group-1"], "blocked", ["app-1"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" + + def test_all_users_blocked_with_all_apps_allowed_passes(self): + """Blocking every user restricts the setting whatever the applications.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["AllUsers"], "allowed", ["AllApplications"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py new file mode 100644 index 0000000000..de439bf599 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py @@ -0,0 +1,578 @@ +from datetime import date +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import Group +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_dynamic_group_member_of_rule_not_used." + "entra_dynamic_group_member_of_rule_not_used" +) + + +class Test_entra_dynamic_group_member_of_rule_not_used: + """Tests for the entra_dynamic_group_member_of_rule_not_used check.""" + + def test_no_dynamic_groups(self): + """No dynamic groups exist — check returns no findings.""" + entra_client = mock.MagicMock() + entra_client.groups = [] + entra_client.groups_error = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_only_assigned_groups(self): + """Assigned (non-dynamic) groups produce no findings.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_groups_error_returns_manual(self): + """When groups could not be read, emit a single MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.groups_error = ( + "Unable to retrieve groups from Microsoft Graph (ODataError)" + ) + entra_client.groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "groups" + assert result[0].resource_name == "Dynamic groups" + assert "Cannot evaluate" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_dynamic_group_null_rule_returns_pass(self): + """A dynamic group with a null membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-null-rule", + name="Null Rule Group", + groupTypes=["DynamicMembership"], + membershipRule=None, + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has no membership rule" in result[0].status_extended + + def test_dynamic_group_empty_rule_returns_pass(self): + """A dynamic group with an empty membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-empty-rule", + name="Empty Rule Group", + groupTypes=["DynamicMembership"], + membershipRule="", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_pass_no_memberof_rule(self): + """A dynamic group with attribute-based rule passes.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-pass", + name="Guests", + groupTypes=["DynamicMembership"], + membershipRule='(user.userType -eq "Guest")', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "g-pass" + assert result[0].resource_name == "Guests" + assert "does not use the memberOf" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_user_memberof_before_retirement(self): + """A dynamic group using user.memberof fails with pre-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-user", + name="All Finance (memberOf)", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['0282e19d-bf41-435d-92a4-99bab93af305','11111111-2222-3333-4444-555555555555'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "g-fail-user" + assert "processing: On" in result[0].status_extended + assert "is being retired on November 3, 2026" in result[0].status_extended + assert "0282e19d-bf41-435d-92a4-99bab93af305" in result[0].status_extended + assert "11111111-2222-3333-4444-555555555555" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_device_memberof_after_retirement(self): + """A dynamic group using device.memberof fails with post-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-device", + name="Corp Devices", + groupTypes=["DynamicMembership"], + membershipRule="device.memberOf -any (group.objectId -in ['aabbccdd-1122-3344-5566-778899001122'])", + membership_rule_processing_state="Paused", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 11, 3) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Paused" in result[0].status_extended + assert "was retired on November 3, 2026" in result[0].status_extended + assert "aabbccdd-1122-3344-5566-778899001122" in result[0].status_extended + + def test_no_false_positive_memberof_in_quoted_string(self): + """memberOf inside a quoted literal must not trigger a FAIL.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-quoted", + name="Dept Group", + groupTypes=["DynamicMembership"], + membershipRule='user.department -eq "user.memberOf"', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_memberof_after_backtick_escaped_quote(self): + """A backtick-escaped quote in an earlier literal must not hide memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-escaped", + name="Escaped Quote Group", + groupTypes=["DynamicMembership"], + membershipRule=( + 'user.department -eq "R`"D" -or user.memberof -any ' + "(group.objectId -in ['11111111-1111-1111-1111-111111111111']) " + '-or user.city -eq "Madrid"' + ), + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_case_insensitive_detection(self): + """Detection is case-insensitive (User.MemberOf should FAIL).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-case", + name="Case Group", + groupTypes=["DynamicMembership"], + membershipRule="User.MemberOf -any (group.objectId -in ['aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiline_rule_normalised(self): + """Rules with CR/LF are normalised before matching.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-multiline", + name="Multiline Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof\r\n-any (group.objectId -in ['12345678-1234-1234-1234-123456789abc'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "12345678-1234-1234-1234-123456789abc" in result[0].status_extended + + def test_mixed_groups_pass_and_fail(self): + """Multiple groups: one PASS, one FAIL, one assigned (skipped).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ), + Group( + id="g-good", + name="Good Dynamic", + groupTypes=["DynamicMembership"], + membershipRule='(user.department -eq "Engineering")', + membership_rule_processing_state="On", + ), + Group( + id="g-bad", + name="Bad Dynamic", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['abcdef01-2345-6789-abcd-ef0123456789'])", + membership_rule_processing_state="On", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + # Assigned group is skipped, so 2 findings + assert len(result) == 2 + statuses = {r.resource_id: r.status for r in result} + assert statuses["g-good"] == "PASS" + assert statuses["g-bad"] == "FAIL" + + def test_fail_unknown_processing_state(self): + """When processing state is None, it shows as 'Unknown'.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-unknown-state", + name="Unknown State Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['aabbccdd-0000-1111-2222-333344445555'])", + membership_rule_processing_state=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Unknown" in result[0].status_extended + + def test_duplicate_guids_deduplicated(self): + """Duplicate GUIDs in the rule are deduplicated in status_extended.""" + guid = "aabbccdd-1122-3344-5566-778899001122" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-dup-guid", + name="Dup GUID Group", + groupTypes=["DynamicMembership"], + membershipRule=f"user.memberof -any (group.objectId -in ['{guid}','{guid}'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + # GUID should appear only once in the source group list + assert result[0].status_extended.count(guid) == 1 diff --git a/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py b/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py new file mode 100644 index 0000000000..9b007de452 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py @@ -0,0 +1,155 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_admin_self_service_password_reset_disabled: + def test_no_auth_policy(self): + """When authorization_policy is None, the check returns a MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot evaluate" in result[0].status_extended + assert "could not be retrieved" in result[0].status_extended + assert result[0].resource_name == "Authorization Policy" + assert result[0].resource_id == "authorizationPolicy" + + def test_auth_policy_sspr_none(self): + """When allowed_to_use_sspr is None, the check returns a MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=None, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot evaluate" in result[0].status_extended + assert result[0].resource_name == "Authorization Policy" + assert result[0].resource_id == "authorizationPolicy" + + def test_sspr_enabled_fail(self): + """When allowed_to_use_sspr is True, the check returns FAIL.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=True, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Self-Service Password Reset (SSPR) is enabled for administrators " + "(allowedToUseSSPR is true in the authorization policy)." + ) + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert result[0].location == "global" + + def test_sspr_disabled_pass(self): + """When allowed_to_use_sspr is False, the check returns PASS.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=False, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Self-Service Password Reset (SSPR) is disabled for administrators " + "(allowedToUseSSPR is false in the authorization policy)." + ) + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py new file mode 100644 index 0000000000..d6be165b04 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py @@ -0,0 +1,147 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, + DefaultUserRolePermissions, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_msol_powershell_blocked: + """Tests for the entra_policy_msol_powershell_blocked check.""" + + def test_authorization_policy_none(self): + """MANUAL when the authorization policy could not be retrieved.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = None + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be retrieved" in result[0].status_extended + + def test_block_msol_powershell_none(self): + """MANUAL when blockMsolPowerShell is missing/null in the response.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=None, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be determined" in result[0].status_extended + + def test_block_msol_powershell_true(self): + """PASS when blockMsolPowerShell is true.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=True, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + ) + + def test_block_msol_powershell_false(self): + """FAIL when blockMsolPowerShell is false.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=False, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + ) diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 1c98973e0a..febaee7e30 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1990,3 +1990,253 @@ class TestGetUsersError: assert users == {} assert service.users_error is not None assert "Unable to retrieve users from Microsoft Graph" in service.users_error + + +class TestAuthorizationPolicyMsolMapping: + """``blockMsolPowerShell`` from the Graph SDK model reaches ``AuthorizationPolicy``.""" + + @pytest.mark.parametrize("value", [True, False, None]) + def test_block_msol_powershell_is_mapped(self, value): + from msgraph.generated.models.authorization_policy import ( + AuthorizationPolicy as GraphAuthorizationPolicy, + ) + + graph_policy = GraphAuthorizationPolicy( + id="authorizationPolicy", + display_name="Authorization Policy", + description="Used to manage authorization related settings across the company.", + block_msol_power_shell=value, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + authorization_policy=SimpleNamespace( + get=AsyncMock(return_value=graph_policy) + ) + ) + ) + + policy = asyncio.run(service._get_authorization_policy()) + + assert policy.block_msol_powershell is value + + +class TestAppRegistrationRedirectUris: + def test_redirect_uris_are_read_for_every_platform(self): + app = SimpleNamespace( + id="obj-1", + app_id="app-1", + display_name="Portal", + password_credentials=[], + key_credentials=[], + web=SimpleNamespace(redirect_uris=["https://portal.contoso.com/signin"]), + spa=SimpleNamespace(redirect_uris=["http://localhost:3000"]), + public_client=SimpleNamespace(redirect_uris=["msalapp-1://auth"]), + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-1"].web_redirect_uris == ["https://portal.contoso.com/signin"] + assert apps["obj-1"].spa_redirect_uris == ["http://localhost:3000"] + assert apps["obj-1"].public_client_redirect_uris == ["msalapp-1://auth"] + + def test_missing_platforms_give_empty_lists(self): + app = SimpleNamespace( + id="obj-2", + app_id="app-2", + display_name="Daemon", + password_credentials=[], + key_credentials=[], + web=None, + spa=None, + public_client=None, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-2"].web_redirect_uris == [] + assert apps["obj-2"].spa_redirect_uris == [] + assert apps["obj-2"].public_client_redirect_uris == [] + + +class TestGetGroups: + @staticmethod + def _graph_group(group_id, rule=None, processing_state=None): + return SimpleNamespace( + id=group_id, + display_name=f"Group {group_id}", + group_types=["DynamicMembership"] if rule else None, + membership_rule=rule, + membership_rule_processing_state=processing_state, + is_assignable_to_role=None, + is_management_restricted=None, + ) + + def test_maps_dynamic_rule_fields_across_pages(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[ + self._graph_group( + "g-1", + rule="user.memberof -any (group.objectId -in ['x'])", + processing_state="Paused", + ) + ], + odata_next_link="next-link", + ) + page_two = SimpleNamespace( + value=[self._graph_group("g-2")], odata_next_link=None + ) + next_page_builder = SimpleNamespace(get=AsyncMock(return_value=page_two)) + with_url_mock = MagicMock(return_value=next_page_builder) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), with_url=with_url_mock + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + with_url_mock.assert_called_once_with("next-link") + assert entra_service.groups_error is None + assert [group.id for group in groups] == ["g-1", "g-2"] + assert groups[0].groupTypes == ["DynamicMembership"] + assert groups[0].membershipRule.startswith("user.memberof") + assert groups[0].membership_rule_processing_state == "Paused" + assert groups[1].groupTypes == [] + assert groups[1].membershipRule is None + assert groups[1].membership_rule_processing_state is None + assert groups[1].is_assignable_to_role is False + + def test_pagination_error_keeps_partial_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[self._graph_group("g-1")], odata_next_link="next-link" + ) + failing_builder = SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("throttled")) + ) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), + with_url=MagicMock(return_value=failing_builder), + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert [group.id for group in groups] == ["g-1"] + assert "pagination error: RuntimeError" in entra_service.groups_error + + def test_initial_error_returns_no_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(side_effect=PermissionError("forbidden")) + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert groups == [] + assert entra_service.groups_error == ( + "Unable to retrieve groups from Microsoft Graph (PermissionError)" + ) + + +class TestCrossTenantAccessDefault: + """The Graph SDK default policy is mapped to ``CrossTenantAccessDefault``.""" + + def test_maps_outbound_settings_from_the_sdk_model(self): + from msgraph.generated.models.cross_tenant_access_policy_b2_b_setting import ( + CrossTenantAccessPolicyB2BSetting, + ) + from msgraph.generated.models.cross_tenant_access_policy_configuration_default import ( + CrossTenantAccessPolicyConfigurationDefault, + ) + from msgraph.generated.models.cross_tenant_access_policy_target import ( + CrossTenantAccessPolicyTarget, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_configuration import ( + CrossTenantAccessPolicyTargetConfiguration, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_configuration_access_type import ( + CrossTenantAccessPolicyTargetConfigurationAccessType as AccessType, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_type import ( + CrossTenantAccessPolicyTargetType as TargetType, + ) + + def config(access, target, target_type): + return CrossTenantAccessPolicyTargetConfiguration( + access_type=access, + targets=[ + CrossTenantAccessPolicyTarget( + target=target, target_type=target_type + ) + ], + ) + + graph_policy = CrossTenantAccessPolicyConfigurationDefault( + b2b_collaboration_outbound=CrossTenantAccessPolicyB2BSetting( + users_and_groups=config( + AccessType.Blocked, "group-1", TargetType.Group + ), + applications=config( + AccessType.Allowed, "AllApplications", TargetType.Application + ), + ), + b2b_direct_connect_outbound=None, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + cross_tenant_access_policy=SimpleNamespace( + default=SimpleNamespace(get=AsyncMock(return_value=graph_policy)) + ) + ) + ) + + policy = asyncio.run(service._get_cross_tenant_access_default()) + + users = policy.b2b_collaboration_outbound.users_and_groups + apps = policy.b2b_collaboration_outbound.applications + assert users.access_type == "blocked" + assert users.targets[0].target == "group-1" + assert users.targets[0].target_type == "group" + assert apps.access_type == "allowed" + assert apps.targets[0].target == "AllApplications" + assert policy.b2b_direct_connect_outbound is None + + def test_read_error_returns_none(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + cross_tenant_access_policy=SimpleNamespace( + default=SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("403")) + ) + ) + ) + ) + + assert asyncio.run(service._get_cross_tenant_access_default()) is None diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 5d62b9da6f..f65555d84e 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.45.0] (Prowler v5.45.0) + +### 🚀 Added + +- Option to invite a teammate from the AWS connect step when the user cannot access the account credentials [(#12917)](https://github.com/prowler-cloud/prowler/pull/12917) + +### 🐞 Fixed + +- Required home region selector in the OCI credentials form, so tenancies not subscribed to us-ashburn-1 can connect [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865) +- First-login redirect to the add-provider wizard is retried on the next load when the navigation was cut short, on Cloud and self-hosted alike, instead of being written off after a single attempt [(#12914)](https://github.com/prowler-cloud/prowler/pull/12914) +- Slack partial channel list message tells the user to invite the app to a missing channel [(#13036)](https://github.com/prowler-cloud/prowler/pull/13036) +- Registry provider discovery distinguishes paid-plan upgrades, missing credentials, and connection failures [(#13038)](https://github.com/prowler-cloud/prowler/pull/13038) + +### 🔐 Security + +- `next` to 16.3.6, patching a remote code execution in `next/og` `ImageResponse` (GHSA-vcvr-r3jv-pc5j) [(#12922)](https://github.com/prowler-cloud/prowler/pull/12922) +- Kubernetes kubeconfig form validation accepts only inline credentials and known cluster fields, rejecting file-path, command-based, `auth-provider`, proxy URL and unknown fields before submission [(#12952)](https://github.com/prowler-cloud/prowler/pull/12952) +- `proxy-addr` to 2.0.8, patching IP spoofing through IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h) [(#12957)](https://github.com/prowler-cloud/prowler/pull/12957) +- `sharp` to 0.35.5, patching a librsvg memory vulnerability that can lead to remote code execution on glibc-based Linux (GHSA-wq5f-xc86-pv6w) [(#12968)](https://github.com/prowler-cloud/prowler/pull/12968) +- `next` to 16.3.8, patching a server-side request forgery in Image Optimization (GHSA-cjq9-62q9-8jv4) [(#12987)](https://github.com/prowler-cloud/prowler/pull/12987) + +--- + ## [1.44.0] (Prowler v5.44.0) ### 🚀 Added diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts index e84fab1218..e0d82ba91d 100644 --- a/ui/actions/registry/registry.adapter.ts +++ b/ui/actions/registry/registry.adapter.ts @@ -190,6 +190,9 @@ export async function classifyRegistryFailure( endpoint: RegistryEndpoint, credentialStatus: RegistryCredentialStatus | null, ): Promise { + if (response.status === 402) { + return { status: REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED }; + } if (response.status === 401 || response.status === 403) { return { status: REGISTRY_FAILURE.ACCESS_DENIED }; } @@ -198,15 +201,16 @@ export async function classifyRegistryFailure( return { status: REGISTRY_FAILURE.ERROR }; } + const code = await getRegistryErrorCode(response); if ( response.status === 409 && - credentialStatus !== null && - !isActiveRegistryCredential(credentialStatus) + (code === "registry_credential_not_configured" || + (credentialStatus !== null && + !isActiveRegistryCredential(credentialStatus))) ) { return { status: REGISTRY_FAILURE.ONBOARDING }; } - const code = await getRegistryErrorCode(response); if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) { return { status: REGISTRY_FAILURE.RECONNECT }; } diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts index a3a44ffcf9..5f85fcc27a 100644 --- a/ui/actions/registry/registry.test.ts +++ b/ui/actions/registry/registry.test.ts @@ -115,6 +115,45 @@ function mockRequestDeadlines() { } describe("installed Registry provider discovery", () => { + it("reports a missing Registry key as setup rather than a connection failure", async () => { + // Given + mockDiscovery({ + failedEndpoint: "providers", + failureStatus: 409, + failureBody: { errors: [{ code: "registry_credential_not_configured" }] }, + }); + + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "onboarding", + }); + }); + + it("shows the paywall when bootstrapping Registry requires a subscription", async () => { + // Given + fetchMock.mockResolvedValueOnce(jsonResponse({}, 402)); + + // When / Then + expect(await getRegistryBootstrap()).toEqual({ + status: "subscription_required", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each(["available-artifacts", "artifacts", "providers"])( + "offers a paid plan when the %s read requires a subscription", + async (failedEndpoint) => { + // Given + mockDiscovery({ failedEndpoint, failureStatus: 402 }); + + // When + const result = await getInstalledRegistryProviderOptions(); + + // Then + expect(result).toEqual({ status: "subscription_required" }); + }, + ); + function mockDiscovery({ emptyMetadata = false, failedEndpoint, diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts index 18cd2322ce..e6a1ae3964 100644 --- a/ui/actions/registry/registry.ts +++ b/ui/actions/registry/registry.ts @@ -138,6 +138,8 @@ async function classifyDiscoveryFailure( credential: RegistryCredentialStatus | null, ) { if (response.status === 409 && credential === null) { + const failure = await classifyRegistryFailure(response, endpoint, null); + if (failure.status === REGISTRY_FAILURE.ONBOARDING) return failure; const currentCredential = await readRegistryCredential(accessToken); if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) { return currentCredential; @@ -211,6 +213,18 @@ export async function getInstalledRegistryProviderOptions(): Promise status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED, + ) + ) + return { status: REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED }; + if ( + [catalog.status, providers.status].some( + (status) => status === REGISTRY_FAILURE.ONBOARDING, + ) + ) + return { status: REGISTRY_PROVIDER_DISCOVERY.ONBOARDING }; if ( catalog.status !== REGISTRY_CATALOG.COMPLETE || installed.status !== "ready" || @@ -303,8 +317,11 @@ function bootstrapReady( function bootstrapFailure( failure: RegistryFailureResult, ): RegistryBootstrapResult { - if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) { - return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + if ( + failure.status === REGISTRY_FAILURE.ACCESS_DENIED || + failure.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED + ) { + return { status: failure.status }; } return bootstrapReady({ status: @@ -340,8 +357,11 @@ export async function getRegistryBootstrap(): Promise { } const catalog = await readCompleteRegistryCatalog(access, credential); - if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) { - return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + if ( + catalog.status === REGISTRY_FAILURE.ACCESS_DENIED || + catalog.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED + ) { + return { status: catalog.status }; } if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) { return bootstrapReady({ diff --git a/ui/app/(prowler)/registry/page.test.tsx b/ui/app/(prowler)/registry/page.test.tsx new file mode 100644 index 0000000000..c87e2014ae --- /dev/null +++ b/ui/app/(prowler)/registry/page.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getRegistryBootstrap } from "@/actions/registry/registry"; + +import RegistryPage from "./page"; + +vi.mock("@/actions/registry/registry", () => ({ + getRegistryBootstrap: vi.fn(), +})); +vi.mock("@/lib/registry/presentation", () => ({ + readRegistryPresentation: () => ({}), +})); +vi.mock("@/components/shadcn/content-layout/content-layout", () => ({ + ContentLayout: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/components/registry/registry-explorer", () => ({ + RegistryExplorer: () =>

Registry catalog

, +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`); + }), +})); + +describe("Registry paid access", () => { + beforeEach(() => vi.clearAllMocks()); + + it("offers an upgrade on direct visits without loading the catalog", async () => { + // Given + vi.mocked(getRegistryBootstrap).mockResolvedValue({ + status: "subscription_required", + }); + + // When + render(await RegistryPage()); + + // Then + expect( + screen.getByRole("button", { name: "Unlock Registry" }), + ).toBeVisible(); + expect(screen.queryByText("Registry catalog")).not.toBeInTheDocument(); + }); + + it("keeps access denial separate from a paid plan upgrade", async () => { + // Given + vi.mocked(getRegistryBootstrap).mockResolvedValue({ + status: "access_denied", + }); + + // When / Then + await expect(RegistryPage()).rejects.toThrow("redirect:/profile"); + }); +}); diff --git a/ui/app/(prowler)/registry/page.tsx b/ui/app/(prowler)/registry/page.tsx index 36c2bb90f6..cf0c61c1d3 100644 --- a/ui/app/(prowler)/registry/page.tsx +++ b/ui/app/(prowler)/registry/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { getRegistryBootstrap } from "@/actions/registry/registry"; import { RegistryExplorer } from "@/components/registry/registry-explorer"; +import { RegistryUpgradePrompt } from "@/components/registry/registry-upgrade-prompt"; import { ContentLayout } from "@/components/shadcn/content-layout/content-layout"; import { readRegistryPresentation } from "@/lib/registry/presentation"; import { REGISTRY_FAILURE } from "@/types/registry"; @@ -15,10 +16,14 @@ export default async function RegistryPage() { return ( }> - + {bootstrap.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED ? ( + + ) : ( + + )} ); } diff --git a/ui/changelog.d/aws-invite-teammate.added.md b/ui/changelog.d/aws-invite-teammate.added.md deleted file mode 100644 index 150bec96c1..0000000000 --- a/ui/changelog.d/aws-invite-teammate.added.md +++ /dev/null @@ -1 +0,0 @@ -Option to invite a teammate from the AWS connect step when the user cannot access the account credentials diff --git a/ui/changelog.d/kubeconfig-inline-credentials.security.md b/ui/changelog.d/kubeconfig-inline-credentials.security.md deleted file mode 100644 index 051869feec..0000000000 --- a/ui/changelog.d/kubeconfig-inline-credentials.security.md +++ /dev/null @@ -1 +0,0 @@ -Kubernetes kubeconfig form validation accepts only inline credentials and known cluster fields, rejecting file-path, command-based, `auth-provider`, proxy URL and unknown fields before submission diff --git a/ui/changelog.d/next-image-optimization-ssrf.security.md b/ui/changelog.d/next-image-optimization-ssrf.security.md deleted file mode 100644 index 37c141726f..0000000000 --- a/ui/changelog.d/next-image-optimization-ssrf.security.md +++ /dev/null @@ -1 +0,0 @@ -`next` to 16.3.8, patching a server-side request forgery in Image Optimization (GHSA-cjq9-62q9-8jv4) diff --git a/ui/changelog.d/next-og-image-response-rce.security.md b/ui/changelog.d/next-og-image-response-rce.security.md deleted file mode 100644 index d9f4eb4894..0000000000 --- a/ui/changelog.d/next-og-image-response-rce.security.md +++ /dev/null @@ -1 +0,0 @@ -`next` to 16.3.6, patching a remote code execution in `next/og` `ImageResponse` (GHSA-vcvr-r3jv-pc5j) diff --git a/ui/changelog.d/oci-home-region-bootstrap.fixed.md b/ui/changelog.d/oci-home-region-bootstrap.fixed.md deleted file mode 100644 index 3982d0b7d1..0000000000 --- a/ui/changelog.d/oci-home-region-bootstrap.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Required home region selector in the OCI credentials form, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/ui/changelog.d/proxy-addr-ip-spoofing.security.md b/ui/changelog.d/proxy-addr-ip-spoofing.security.md deleted file mode 100644 index 9574c50d16..0000000000 --- a/ui/changelog.d/proxy-addr-ip-spoofing.security.md +++ /dev/null @@ -1 +0,0 @@ -`proxy-addr` to 2.0.8, patching IP spoofing through IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h) diff --git a/ui/changelog.d/sharp-librsvg-vulnerability.security.md b/ui/changelog.d/sharp-librsvg-vulnerability.security.md deleted file mode 100644 index 4fe262e6cd..0000000000 --- a/ui/changelog.d/sharp-librsvg-vulnerability.security.md +++ /dev/null @@ -1 +0,0 @@ -`sharp` to 0.35.5, patching a librsvg memory vulnerability that can lead to remote code execution on glibc-based Linux (GHSA-wq5f-xc86-pv6w) diff --git a/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md b/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md deleted file mode 100644 index e2d6c268ee..0000000000 --- a/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md +++ /dev/null @@ -1 +0,0 @@ -First-login redirect to the add-provider wizard is retried on the next load when the navigation was cut short, on Cloud and self-hosted alike, instead of being written off after a single attempt diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx index 763cbcb4b3..4f090b7fce 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.test.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -9,6 +9,10 @@ const { addProvider, updateProvider, getInstalledRegistryProviderOptions } = getInstalledRegistryProviderOptions: vi.fn(), })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ permissions: { manage_registry: true } }), +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -23,6 +27,8 @@ vi.mock("@/actions/registry/registry", () => ({ getInstalledRegistryProviderOptions, })); +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; + import { ConnectAccountForm } from "./connect-account-form"; describe("provider account aliases", () => { @@ -84,6 +90,57 @@ describe("provider account aliases", () => { }); describe("Registry provider source tabs", () => { + it("explains Registry setup without claiming a connection failure", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "onboarding", + }); + + // When + render(); + + // Then + expect( + await screen.findByText("Registry is not connected yet"), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Unlock Registry" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + }); + + it("offers the Registry paid plan upgrade while keeping built-in providers usable", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "subscription_required", + }); + const user = userEvent.setup(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + + // When + render(); + await user.click( + await screen.findByRole("button", { name: "Unlock Registry" }), + ); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe("registry"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Retry Registry providers" }), + ).not.toBeInTheDocument(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index f0199a0c80..3380aa9c0d 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -8,20 +8,19 @@ import { useForm, UseFormReturn } from "react-hook-form"; import { addProvider, updateProvider } from "@/actions/providers/providers"; import { addRegistryProvider } from "@/actions/providers/registry-provider"; -import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector"; import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; +import { + RegistryUpgradePrompt, + RegistrySetupPrompt, +} from "@/components/registry/registry-upgrade-prompt"; import { Button, useToast } from "@/components/shadcn"; import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; import { Form } from "@/components/shadcn/form"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; -import { - REGISTRY_PROVIDER_DISCOVERY, - type RegistryProviderOption, -} from "@/lib/registry/provider-options"; -import { isCloud } from "@/lib/shared/env"; +import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options"; import { createAddProviderFormSchema, AddProviderFormValues, @@ -38,6 +37,8 @@ import { isKnownProviderType } from "@/types/providers"; import { RadioGroupProvider } from "../../radio-group-provider"; +import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery"; + export type FormValues = AddProviderFormValues; export interface ConnectAccountSuccessData { @@ -219,60 +220,25 @@ export const ConnectAccountForm = ({ const [method, setMethod] = useState<"single" | null>(null); const router = useRouter(); - const [registryOptions, setRegistryOptions] = useState< - RegistryProviderOption[] - >([]); - // Only confirmed Cloud and Private Cloud access enables Registry source tabs. - // Unknown access stays hidden but remains retryable through the warning. - const [registryAvailable, setRegistryAvailable] = useState(false); - const [registryError, setRegistryError] = useState(false); + const { + result: registryDiscovery, + isRetryingDiscovery, + retryDiscovery, + } = useRegistryProviderDiscovery(); + const registryOptions = + registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY + ? registryDiscovery.options + : []; + const registryAvailable = + registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY || + registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR; + const registryError = + registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR || + registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN; const [providerError, setProviderError] = useState(null); - const [discoveryAttempt, setDiscoveryAttempt] = useState(0); - // Local state needed: a request in flight cannot be derived from the attempt count. - const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false); const submitting = useRef(false); const createdAccount = useRef(null); - useEffect(() => { - // Registry is Cloud-only: elsewhere never ask, so a failure cannot surface it. - if (!isCloud()) return; - let active = true; - const load = async () => { - try { - const result = await getInstalledRegistryProviderOptions(); - if (!active) return; - setRegistryOptions( - result.status === REGISTRY_PROVIDER_DISCOVERY.READY - ? result.options - : [], - ); - setRegistryAvailable( - result.status === REGISTRY_PROVIDER_DISCOVERY.READY || - result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR, - ); - setRegistryError( - result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR || - result.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN, - ); - } catch { - if (active) { - setRegistryOptions([]); - setRegistryAvailable(false); - setRegistryError(true); - } - } - }; - // Only this effect's own load ends a retry; event reloads must not. - void load().then(() => { - if (active) setIsRetryingDiscovery(false); - }); - window.addEventListener("registry-artifacts-changed", load); - return () => { - active = false; - window.removeEventListener("registry-artifacts-changed", load); - }; - }, [discoveryAttempt]); - const formSchema = createAddProviderFormSchema( registryOptions.map((option) => option.type), ); @@ -474,6 +440,12 @@ export const ConnectAccountForm = ({ {/* Step 1: Provider selection */} {prevStep === 1 && (
+ {registryDiscovery?.status === + REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && ( + + )} + {registryDiscovery?.status === + REGISTRY_PROVIDER_DISCOVERY.ONBOARDING && } {registryError && ( Registry providers could not be loaded @@ -485,11 +457,7 @@ export const ConnectAccountForm = ({ type="button" variant="outline" aria-disabled={isRetryingDiscovery} - onClick={() => { - if (isRetryingDiscovery) return; - setIsRetryingDiscovery(true); - setDiscoveryAttempt((attempt) => attempt + 1); - }} + onClick={retryDiscovery} > {isRetryingDiscovery ? "Retrying…" diff --git a/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts b/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts new file mode 100644 index 0000000000..b16784f16d --- /dev/null +++ b/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts @@ -0,0 +1,77 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; +import type { RegistryProviderDiscoveryResult } from "@/lib/registry/provider-options"; + +import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery"; + +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions: vi.fn(), +})); + +function deferred() { + let resolve!: (value: RegistryProviderDiscoveryResult) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("useRegistryProviderDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + }); + + it("ignores a stale discovery that settles after a newer one", async () => { + // Given + const initial = deferred(); + const afterChange = deferred(); + vi.mocked(getInstalledRegistryProviderOptions) + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(afterChange.promise); + const { result } = renderHook(() => useRegistryProviderDiscovery()); + + // When + act(() => { + window.dispatchEvent(new Event("registry-artifacts-changed")); + }); + await act(async () => { + afterChange.resolve({ status: "subscription_required" }); + }); + await act(async () => { + initial.resolve({ status: "ready", options: [] }); + }); + + // Then + await waitFor(() => + expect(result.current.result).toEqual({ + status: "subscription_required", + }), + ); + }); + + it("applies a retry even when the request it replaces settles later", async () => { + // Given + const initial = deferred(); + const retry = deferred(); + vi.mocked(getInstalledRegistryProviderOptions) + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(retry.promise); + const { result } = renderHook(() => useRegistryProviderDiscovery()); + + // When + act(() => result.current.retryDiscovery()); + await act(async () => { + retry.resolve({ status: "onboarding" }); + }); + await act(async () => { + initial.resolve({ status: "error" }); + }); + + // Then + await waitFor(() => expect(result.current.isRetryingDiscovery).toBe(false)); + expect(result.current.result).toEqual({ status: "onboarding" }); + }); +}); diff --git a/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts b/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts new file mode 100644 index 0000000000..f6add31968 --- /dev/null +++ b/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; +import { + REGISTRY_PROVIDER_DISCOVERY, + type RegistryProviderDiscoveryResult, +} from "@/lib/registry/provider-options"; +import { isCloud } from "@/lib/shared/env"; + +export function useRegistryProviderDiscovery() { + // Local state needed: discovery and retries resolve asynchronously. + const [result, setResult] = useState( + null, + ); + const [attempt, setAttempt] = useState(0); + const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false); + // Initial, retry and artifact-change loads overlap: only the newest applies. + const latestRequest = useRef(0); + + useEffect(() => { + if (!isCloud()) return; + let active = true; + const load = async () => { + const request = ++latestRequest.current; + const isCurrent = () => active && request === latestRequest.current; + try { + const discovery = await getInstalledRegistryProviderOptions(); + if (isCurrent()) setResult(discovery); + } catch { + if (isCurrent()) + setResult({ status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN }); + } + }; + void load().then(() => { + if (active) setIsRetryingDiscovery(false); + }); + window.addEventListener("registry-artifacts-changed", load); + return () => { + active = false; + window.removeEventListener("registry-artifacts-changed", load); + }; + }, [attempt]); + + const retryDiscovery = () => { + if (isRetryingDiscovery) return; + setIsRetryingDiscovery(true); + setAttempt((current) => current + 1); + }; + + return { result, isRetryingDiscovery, retryDiscovery }; +} diff --git a/ui/components/registry/registry-explorer.integration.test.tsx b/ui/components/registry/registry-explorer.integration.test.tsx index 4641c873c3..bf2101b318 100644 --- a/ui/components/registry/registry-explorer.integration.test.tsx +++ b/ui/components/registry/registry-explorer.integration.test.tsx @@ -440,6 +440,28 @@ describe("RegistryExplorer", () => { expect(cardFor("AWS guard")).toBeTruthy(); }); + it("re-renders the page when a refresh reports a lapsed subscription", async () => { + // Given + const screen = await render(); + refreshRegistryCollectionsMock.mockResolvedValue({ + status: "subscription_required", + }); + // When + await screen.getByRole("button", { name: "Refresh Registry" }).click(); + // Then + // The mocked router never swaps the page, so the explorer may refresh again. + await expect + .poll(() => vi.mocked(registryRouter.refresh).mock.calls.length) + .toBeGreaterThanOrEqual(1); + expect(vi.mocked(registryRouter.replace)).not.toHaveBeenCalled(); + await expect + .element(screen.getByRole("button", { name: "Refresh Registry" })) + .toBeEnabled(); + expect(document.body.textContent).not.toContain( + "Registry could not be refreshed.", + ); + }); + it.each(["access_denied", "reconnect", "onboarding"])( "honors %s from a collection refresh", async (status) => { diff --git a/ui/components/registry/registry-explorer.tsx b/ui/components/registry/registry-explorer.tsx index 8ac02f93ec..14b04b52a9 100644 --- a/ui/components/registry/registry-explorer.tsx +++ b/ui/components/registry/registry-explorer.tsx @@ -182,6 +182,9 @@ export function RegistryExplorer({ onResult: (result) => { if (result.status === "access_denied") { router.replace("/profile"); + } else if (result.status === "subscription_required") { + // The server page swaps the catalog for the paid-plan prompt. + router.refresh(); } else if (result.status === "complete") { setRefreshMessage(undefined); setState((current) => diff --git a/ui/components/registry/registry-upgrade-prompt.integration.test.tsx b/ui/components/registry/registry-upgrade-prompt.integration.test.tsx new file mode 100644 index 0000000000..2c32e3e568 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.integration.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, vi } from "vitest"; +import { page } from "vitest/browser"; + +import { it } from "@/__tests__/fixtures"; +import { render } from "@/__tests__/render-browser"; +import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal"; +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; + +import { RegistryUpgradePrompt } from "./registry-upgrade-prompt"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ permissions: { manage_billing: true } }), +})); + +const VIEWPORTS = [ + { name: "mobile", width: 390, height: 844 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1280, height: 800 }, +]; + +describe("Registry paid plan prompt", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it.each(VIEWPORTS)( + "opens the shared paid plan modal on $name", + async ({ name, width, height }) => { + // Given + await page.viewport(width, height); + const screen = await render( +
+ + +
, + ); + + // When + await screen.getByRole("button", { name: "Unlock Registry" }).click(); + + // Then + await expect + .element(page.getByRole("dialog", { name: "Unlock Prowler Registry" })) + .toBeVisible(); + await expect + .element(page.getByRole("link", { name: "Upgrade to Unlock Registry" })) + .toHaveAttribute("href", "/billing?feature=registry"); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width); + await expect + .poll(() => + page + .getByRole("dialog", { name: "Unlock Prowler Registry" }) + .element() + .getAnimations({ subtree: true }) + .every((animation) => animation.playState !== "running"), + ) + .toBe(true); + await page.screenshot({ + path: `../../tests/providers/evidence/registry-paywall-${name}.png`, + }); + }, + ); +}); diff --git a/ui/components/registry/registry-upgrade-prompt.test.tsx b/ui/components/registry/registry-upgrade-prompt.test.tsx new file mode 100644 index 0000000000..c548e14262 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { RegistrySetupPrompt } from "./registry-upgrade-prompt"; + +const { permissions } = vi.hoisted(() => ({ + permissions: { manage_registry: false }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ permissions }), +})); + +describe("RegistrySetupPrompt", () => { + beforeEach(() => { + permissions.manage_registry = false; + }); + + it("sends Registry managers to connect their key", () => { + // Given + permissions.manage_registry = true; + + // When + render(); + + // Then + expect( + screen.getByRole("link", { name: "Connect Registry" }), + ).toHaveAttribute("href", "/registry"); + }); + + it("asks everyone else for an administrator", () => { + // When + render(); + + // Then + expect( + screen.getByText( + "Ask an administrator to connect Registry before adding custom providers.", + ), + ).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Connect Registry" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/registry/registry-upgrade-prompt.tsx b/ui/components/registry/registry-upgrade-prompt.tsx new file mode 100644 index 0000000000..4ab5321879 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; +import { Button } from "@/components/shadcn/button/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +export function RegistryUpgradePrompt() { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + return ( + + Registry is available on paid plans + + Unlock custom providers, security checks, and compliance frameworks. + + + + ); +} + +export function RegistrySetupPrompt() { + const { permissions } = useAuth(); + return ( + + Registry is not connected yet + + {permissions.manage_registry === true ? ( + <> + Connect your Registry API key to load custom providers. + + + ) : ( + "Ask an administrator to connect Registry before adding custom providers." + )} + + + ); +} diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index 0687d9c564..5b50a7a5b1 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -51,6 +51,23 @@ vi.mock("@/components/shadcn/modal", async (importOriginal) => { }); describe("CloudUpgradeModal", () => { + it("links the Registry paid plan upgrade to billing", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + useCloudUpgradeStore.getState().openCloudUpgrade("registry"); + + // When + render(); + + // Then + expect( + await screen.findByRole("dialog", { name: "Unlock Prowler Registry" }), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Upgrade to Unlock Registry" }), + ).toHaveAttribute("href", "/billing?feature=registry"); + }); + afterEach(() => { cleanup(); modalTestState.keepContentMounted = false; diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index a3a6dc0e8c..3f4c3bbb49 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -215,10 +215,21 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE = const CLOUD_UTM_SOURCE = "prowler-cloud"; const PAID_PLAN_UPGRADE_UTM_CONTENT = { + [PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry", [PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download", } as const satisfies Record; export const PAID_PLAN_UPGRADE_CONTENT = { + [PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: { + title: "Unlock Prowler Registry", + description: "Prowler Registry is included in paid plans.", + benefits: [ + "Connect custom cloud providers", + "Extend your assessments with custom security checks", + "Use private compliance frameworks across your accounts", + ], + primaryCta: "Upgrade to Unlock Registry", + }, [PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: { title: "Download Your Scan Reports", description: "Report downloads are included in Prowler Cloud paid plans.", diff --git a/ui/lib/integrations/slack-errors.ts b/ui/lib/integrations/slack-errors.ts index d8a3f1cfc4..e79dcc3704 100644 --- a/ui/lib/integrations/slack-errors.ts +++ b/ui/lib/integrations/slack-errors.ts @@ -46,7 +46,7 @@ export const SLACK_RATE_LIMITED_MESSAGE = * out, or `links.next` left the API's origin. */ export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE = - "This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler Cloud has to be invited to."; + "This workspace has more public channels than Prowler reads in one go, so this list is not all of them. Channels @Prowler Cloud is already in are always listed: invite it to a missing channel, then refresh."; /** * For a `2xx` the UI could not read. Not phrased as a failure: the install diff --git a/ui/lib/registry/provider-options.ts b/ui/lib/registry/provider-options.ts index 0c586484df..567a1b760f 100644 --- a/ui/lib/registry/provider-options.ts +++ b/ui/lib/registry/provider-options.ts @@ -12,6 +12,8 @@ export interface RegistryProviderOption { } export const REGISTRY_PROVIDER_DISCOVERY = { + SUBSCRIPTION_REQUIRED: "subscription_required", + ONBOARDING: "onboarding", READY: "ready", ACCESS_DENIED: "access_denied", /** Access could not be evaluated: keep Registry hidden but retryable. */ @@ -20,6 +22,8 @@ export const REGISTRY_PROVIDER_DISCOVERY = { } as const; export type RegistryProviderDiscoveryResult = + | { status: typeof REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED } + | { status: typeof REGISTRY_PROVIDER_DISCOVERY.ONBOARDING } | { status: typeof REGISTRY_PROVIDER_DISCOVERY.READY; options: RegistryProviderOption[]; diff --git a/ui/tests/providers/evidence/registry-paywall-desktop.png b/ui/tests/providers/evidence/registry-paywall-desktop.png new file mode 100644 index 0000000000..40615c3110 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-desktop.png differ diff --git a/ui/tests/providers/evidence/registry-paywall-mobile.png b/ui/tests/providers/evidence/registry-paywall-mobile.png new file mode 100644 index 0000000000..981004a300 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-mobile.png differ diff --git a/ui/tests/providers/evidence/registry-paywall-tablet.png b/ui/tests/providers/evidence/registry-paywall-tablet.png new file mode 100644 index 0000000000..832c6cc067 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-tablet.png differ diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index 39d7fd113e..e612f72f3e 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -19,6 +19,7 @@ export type CloudUpgradeFeature = // Prowler Cloud features gated behind a paid plan. export const PAID_PLAN_UPGRADE_FEATURE = { + REGISTRY: "registry", REPORT_DOWNLOAD: "report_download", } as const; diff --git a/ui/types/registry.ts b/ui/types/registry.ts index 954b794b81..5820214a4e 100644 --- a/ui/types/registry.ts +++ b/ui/types/registry.ts @@ -9,6 +9,7 @@ export type RegistryEndpoint = (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT]; export const REGISTRY_FAILURE = { + SUBSCRIPTION_REQUIRED: "subscription_required", ACCESS_DENIED: "access_denied", ONBOARDING: "onboarding", RECONNECT: "reconnect", @@ -17,6 +18,7 @@ export const REGISTRY_FAILURE = { } as const; export type RegistryFailureResult = + | { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED } | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED } | { status: typeof REGISTRY_FAILURE.ONBOARDING } | { status: typeof REGISTRY_FAILURE.RECONNECT } @@ -300,4 +302,5 @@ export type RegistryBootstrapResult = status: typeof REGISTRY_BOOTSTRAP_STATE.READY; state: RegistryBootstrapState; } - | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }; + | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED } + | { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED }; diff --git a/uv.lock b/uv.lock index 8643a960c3..708037699c 100644 --- a/uv.lock +++ b/uv.lock @@ -972,6 +972,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" }, ] +[[package]] +name = "azure-mgmt-cognitiveservices" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "msrest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/8e/9fcfdd507413a2536c5458b40942705ea8d74fe4e17f05fd1c32bb0225a9/azure_mgmt_cognitiveservices-14.1.0.tar.gz", hash = "sha256:915191374d0adb443863c20aaea2c9f3b9d558a849ac2b78f152249262fdcaf8", size = 184444, upload-time = "2025-10-24T07:28:14.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/ed6bbc01a03991255dd22aa41091360d225f1be20c88710af0f4a6c27459/azure_mgmt_cognitiveservices-14.1.0-py3-none-any.whl", hash = "sha256:3c0eecc00d183842a11d754f4f5c3328ff86f2d6c0a2b81b28dd7e073680258e", size = 290096, upload-time = "2025-10-24T07:28:15.766Z" }, +] + [[package]] name = "azure-mgmt-compute" version = "34.0.0" @@ -1989,7 +2003,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -3767,7 +3781,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.45.0" +version = "5.46.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, @@ -3789,6 +3803,7 @@ dependencies = [ { name = "azure-mgmt-apimanagement" }, { name = "azure-mgmt-applicationinsights" }, { name = "azure-mgmt-authorization" }, + { name = "azure-mgmt-cognitiveservices" }, { name = "azure-mgmt-compute" }, { name = "azure-mgmt-containerregistry" }, { name = "azure-mgmt-containerservice" }, @@ -3913,6 +3928,7 @@ requires-dist = [ { name = "azure-mgmt-apimanagement", specifier = "==5.0.0" }, { name = "azure-mgmt-applicationinsights", specifier = "==4.1.0" }, { name = "azure-mgmt-authorization", specifier = "==4.0.0" }, + { name = "azure-mgmt-cognitiveservices", specifier = "==14.1.0" }, { name = "azure-mgmt-compute", specifier = "==34.0.0" }, { name = "azure-mgmt-containerregistry", specifier = "==12.0.0" }, { name = "azure-mgmt-containerservice", specifier = "==34.1.0" },